@ryuhq/sdk 0.1.14 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +69 -7
  2. package/dist/action.cjs +839 -0
  3. package/dist/action.d.cts +88 -0
  4. package/dist/action.d.ts +88 -0
  5. package/dist/action.js +8 -0
  6. package/dist/agent-plugin.cjs +46 -0
  7. package/dist/agent-plugin.d.cts +43 -33
  8. package/dist/agent-plugin.d.ts +43 -33
  9. package/dist/agent-plugin.js +1 -1
  10. package/dist/agent.cjs +16 -0
  11. package/dist/agent.d.cts +377 -1
  12. package/dist/agent.d.ts +377 -1
  13. package/dist/agent.js +4 -2
  14. package/dist/app-B0Z9Ew_R.d.cts +141 -0
  15. package/dist/app-C-BDJwfG.d.ts +141 -0
  16. package/dist/builder.cjs +1194 -0
  17. package/dist/builder.d.cts +217 -0
  18. package/dist/builder.d.ts +217 -0
  19. package/dist/builder.js +29 -0
  20. package/dist/chunk-4TPUZDTI.js +94 -0
  21. package/dist/chunk-BC3A7HMO.js +164 -0
  22. package/dist/chunk-FZSFZOIN.js +200 -0
  23. package/dist/{chunk-ODFEUVPW.js → chunk-HACAGK65.js} +1 -18
  24. package/dist/chunk-HLKJZAFK.js +361 -0
  25. package/dist/chunk-IKEDLLFY.js +19 -0
  26. package/dist/{chunk-G6FLVEC4.js → chunk-JX6DDRXV.js} +46 -0
  27. package/dist/{chunk-AO2KJRDD.js → chunk-NZKVOSC2.js} +192 -3
  28. package/dist/chunk-QYFUNJOH.js +83 -0
  29. package/dist/chunk-SN2QBJUF.js +148 -0
  30. package/dist/chunk-TLDPEGC7.js +21 -0
  31. package/dist/chunk-TXSHHZF2.js +0 -0
  32. package/dist/chunk-VLIRNNAE.js +154 -0
  33. package/dist/{chunk-MTUBUPIV.js → chunk-Z57QDDJR.js} +8 -227
  34. package/dist/chunk-ZTJWBRUL.js +282 -0
  35. package/dist/cli.cjs +347 -18
  36. package/dist/cli.js +128 -23
  37. package/dist/client-D5U6ssPc.d.cts +84 -0
  38. package/dist/client-D5U6ssPc.d.ts +84 -0
  39. package/dist/index.cjs +810 -86
  40. package/dist/index.d.cts +19 -634
  41. package/dist/index.d.ts +19 -634
  42. package/dist/index.js +82 -699
  43. package/dist/manifest.cjs +200 -4
  44. package/dist/manifest.d.cts +260 -5
  45. package/dist/manifest.d.ts +260 -5
  46. package/dist/manifest.js +15 -1
  47. package/dist/mcp/client.cjs +180 -0
  48. package/dist/mcp/client.d.cts +49 -0
  49. package/dist/mcp/client.d.ts +49 -0
  50. package/dist/mcp/client.js +10 -0
  51. package/dist/mcp/server.cjs +370 -0
  52. package/dist/mcp/server.d.cts +127 -0
  53. package/dist/mcp/server.d.ts +127 -0
  54. package/dist/mcp/server.js +9 -0
  55. package/dist/mcp.cjs +376 -0
  56. package/dist/mcp.d.cts +2 -0
  57. package/dist/mcp.d.ts +2 -0
  58. package/dist/mcp.js +17 -0
  59. package/dist/model.cjs +141 -0
  60. package/dist/model.d.cts +33 -0
  61. package/dist/model.d.ts +33 -0
  62. package/dist/model.js +18 -0
  63. package/dist/plugin.cjs +46 -0
  64. package/dist/plugin.d.cts +215 -0
  65. package/dist/plugin.d.ts +215 -0
  66. package/dist/plugin.js +8 -0
  67. package/dist/runnable.cjs +1438 -0
  68. package/dist/runnable.d.cts +274 -0
  69. package/dist/runnable.d.ts +274 -0
  70. package/dist/runnable.js +34 -0
  71. package/dist/{index-B6SkaAjJ.d.ts → tool-AjkdFvhE.d.ts} +88 -461
  72. package/dist/{index-BvAB5eMk.d.cts → tool-CgzW92O_.d.cts} +88 -461
  73. package/package.json +48 -3
  74. package/src/agent/loop.test.ts +4 -4
  75. package/src/agent/tools.ts +3 -3
  76. package/src/agent-plugin.test.ts +58 -0
  77. package/src/agent-plugin.ts +109 -36
  78. package/src/builder.ts +9 -0
  79. package/src/cli/dev.test.ts +26 -47
  80. package/src/cli/dev.ts +10 -2
  81. package/src/cli-security.test.ts +109 -0
  82. package/src/cli.ts +141 -25
  83. package/src/contracts-lockstep.test.ts +16 -2
  84. package/src/exports-lockstep.test.ts +93 -0
  85. package/src/generated/plugin-manifest.ts +322 -29
  86. package/src/index.ts +53 -0
  87. package/src/manifest-schema.test.ts +30 -1
  88. package/src/manifest.fixtures.test.ts +22 -6
  89. package/src/manifest.test.ts +165 -10
  90. package/src/manifest.ts +454 -188
  91. package/src/mcp/index.ts +18 -0
  92. package/src/mcp/server.ts +2 -1
  93. package/src/model/index.ts +22 -0
  94. package/src/plugin/ryu-plugin.ts +82 -0
  95. package/src/runnable/action.test.ts +128 -0
  96. package/src/runnable/action.ts +202 -0
  97. package/src/runnable/app.test.ts +2 -0
  98. package/src/runnable/app.ts +50 -14
  99. package/src/runnable/index.ts +20 -3
  100. package/src/runnable/primitives.test.ts +34 -0
  101. package/src/runnable/primitives.ts +116 -0
  102. package/src/runnable/runnable-types.ts +3 -0
  103. package/src/runnable/tool.ts +36 -5
  104. package/src/runnable/turn-hook.ts +8 -3
  105. package/src/slash-command.test.ts +69 -0
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @ryuhq/sdk/mcp — MCP server + client surfaces.
3
+ *
4
+ * `McpServer` handles the MCP `initialize` handshake, `tools/list`,
5
+ * `tools/call`, and the content-block envelope over stdio; `listTools` /
6
+ * `callTool` drive a stdio MCP server. Re-exported here so consumers can
7
+ * import from `@ryuhq/sdk/mcp` (or the narrower `/mcp/server` and
8
+ * `/mcp/client` subpaths) as a single entry point.
9
+ */
10
+
11
+ export type { McpStdioCommand, McpTool } from "./client.ts";
12
+ export { callTool, listTools, MCP_PROTOCOL_VERSION } from "./client.ts";
13
+ export type {
14
+ JsonSchema,
15
+ PassthroughRegistration,
16
+ SdkRunnable,
17
+ } from "./server.ts";
18
+ export { McpServer, unwrapContent } from "./server.ts";
package/src/mcp/server.ts CHANGED
@@ -28,7 +28,8 @@ import { callTool, listTools, MCP_PROTOCOL_VERSION } from "./client.ts";
28
28
  /** JSON Schema fragment — enough to describe a tool's input arguments. */
29
29
  export interface JsonSchema {
30
30
  description?: string;
31
- properties?: Record<string, JsonSchema>;
31
+ /** Property schemas may use any JSON Schema dialect or nested shape. */
32
+ properties?: Record<string, unknown>;
32
33
  required?: string[];
33
34
  type?: string;
34
35
  [key: string]: unknown;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @ryuhq/sdk/model — the gateway-mandatory model client.
3
+ *
4
+ * Every model call routes through the Ryu Gateway, never a direct provider.
5
+ * Re-exports the `ModelClient` / `defineModel` surface plus the gateway
6
+ * resolution + egress helpers, so consumers can import from
7
+ * `@ryuhq/sdk/model` as a single entry point.
8
+ */
9
+
10
+ export type {
11
+ ChatDelta,
12
+ ChatMessage,
13
+ ChatResult,
14
+ ModelClientOptions,
15
+ } from "./client.ts";
16
+ export { defineModel, ModelClient } from "./client.ts";
17
+ export {
18
+ assertAllowedEgressUrl,
19
+ DEFAULT_GATEWAY_URL,
20
+ resolveGatewayToken,
21
+ resolveGatewayUrl,
22
+ } from "./gateway.ts";
@@ -125,6 +125,27 @@ export interface ThemeContribution {
125
125
  * are RPCs over the postMessage bridge; the plugin never holds a Core token or
126
126
  * Tauri IPC handle directly. */
127
127
  export interface RyuHostServices {
128
+ /** Read and cooperatively stop Core-visible background processes. */
129
+ background: {
130
+ list(input?: { producer?: string; running_only?: boolean }): Promise<
131
+ {
132
+ process_id: string;
133
+ command: string;
134
+ cwd: string;
135
+ elapsed_ms: number;
136
+ running: boolean;
137
+ [key: string]: unknown;
138
+ }[]
139
+ >;
140
+ stop(input: { process_id: string }): Promise<{
141
+ ok: boolean;
142
+ requested: boolean;
143
+ process_id: string;
144
+ }>;
145
+ };
146
+ /** Read-only feature discovery. Older hosts may omit this method; treat that
147
+ * as an empty capability set rather than assuming a native surface exists. */
148
+ capabilities?(): Promise<RyuHostCapabilities>;
128
149
  /** Run a registered command by id (built-in or contributed). */
129
150
  commands: { execute(id: string, ...args: unknown[]): Promise<unknown> };
130
151
  /** Gateway-governed model access (chat/embed). Mirrors `@ryuhq/sdk` model
@@ -140,6 +161,21 @@ export interface RyuHostServices {
140
161
  * or any other agent field (invariant: no capability returns a secret). Gated
141
162
  * by the `core:list_agents` grant. */
142
163
  listAgents(): Promise<{ id: string; name: string }[]>;
164
+ /** Mobile-native effects. These methods remain optional at the type boundary
165
+ * because desktop, web, extension, and terminal hosts fail closed. */
166
+ native?: {
167
+ haptics(input: RyuNativeHapticsInput): Promise<RyuNativeHapticsResult>;
168
+ notifications: {
169
+ create(
170
+ input: RyuNativeNotificationInput
171
+ ): Promise<RyuNativeNotificationResult>;
172
+ };
173
+ liveActivities: {
174
+ update(
175
+ input: RyuNativeLiveActivityInput
176
+ ): Promise<RyuNativeLiveActivityResult>;
177
+ };
178
+ };
143
179
  /** Open a tab at a path (built-in or a route this plugin contributed). */
144
180
  openTab(path: string): void;
145
181
  /** Read/write the plugin's own Spaces docs (scoped by grant). */
@@ -152,6 +188,52 @@ export interface RyuHostServices {
152
188
  };
153
189
  }
154
190
 
191
+ /** The token-free, read-only native feature inventory returned by a host. */
192
+ export interface RyuHostCapabilities {
193
+ androidOngoingNotifications: boolean;
194
+ browserNotifications: boolean;
195
+ dynamicIsland: boolean;
196
+ haptics: boolean;
197
+ hardwareBleRelay: boolean;
198
+ liveActivities: boolean;
199
+ localNotifications: boolean;
200
+ platform: "android" | "browser" | "ios" | "unknown";
201
+ pushRegistration: boolean;
202
+ quickActions: boolean;
203
+ sounds: boolean;
204
+ }
205
+
206
+ export type RyuNativeHapticStyle = "light" | "success" | "warning" | "error";
207
+
208
+ export interface RyuNativeHapticsInput {
209
+ style: RyuNativeHapticStyle;
210
+ }
211
+
212
+ export interface RyuNativeNotificationInput {
213
+ body: string;
214
+ title: string;
215
+ }
216
+
217
+ export interface RyuNativeLiveActivityInput {
218
+ conversationId: string;
219
+ detail: string;
220
+ status: "running" | "waiting" | "review" | "done" | "error";
221
+ title: string;
222
+ }
223
+
224
+ export interface RyuNativeHapticsResult {
225
+ signaled: true;
226
+ }
227
+
228
+ export interface RyuNativeNotificationResult {
229
+ id: string;
230
+ scheduled: true;
231
+ }
232
+
233
+ export interface RyuNativeLiveActivityResult {
234
+ updated: true;
235
+ }
236
+
155
237
  // ── The host API a plugin's activate() receives ───────────────────────────────
156
238
 
157
239
  /** Everything a plugin can contribute. Each `register*` returns a
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { PluginManifestSchema } from "../manifest.ts";
3
+ import { defineAction } from "./action.ts";
4
+ import type { GatewayClient, RunnableContext } from "./runnable-types.ts";
5
+
6
+ const gateway: GatewayClient = {
7
+ chat: async () => ({ content: "ok", finishReason: "stop" }),
8
+ async *stream() {
9
+ yield { content: "ok", finishReason: null };
10
+ },
11
+ };
12
+
13
+ const context: RunnableContext = { gateway };
14
+
15
+ const ticketAction = defineAction({
16
+ id: "action-create-ticket",
17
+ name: "Create Ticket",
18
+ description: "Create a support ticket for a customer.",
19
+ schema: {
20
+ type: "object",
21
+ properties: {
22
+ customer: { type: "string" },
23
+ summary: { type: "string" },
24
+ },
25
+ required: ["customer", "summary"],
26
+ },
27
+ outputSchema: {
28
+ type: "object",
29
+ properties: {
30
+ id: { type: "string" },
31
+ },
32
+ required: ["id"],
33
+ },
34
+ effect: "mutate",
35
+ needsApproval: true,
36
+ run: async ({ customer, summary }) => ({
37
+ id: `${customer}:${summary}`,
38
+ }),
39
+ });
40
+
41
+ describe("defineAction", () => {
42
+ it("returns a governed action with the same Runnable execution contract", async () => {
43
+ expect(ticketAction.kind).toBe("tool");
44
+ expect(ticketAction.action).toBe(true);
45
+ expect(ticketAction.description).toBe(
46
+ "Create a support ticket for a customer."
47
+ );
48
+ expect(ticketAction.schema.required).toEqual(["customer", "summary"]);
49
+ expect(ticketAction.outputSchema?.required).toEqual(["id"]);
50
+ expect(ticketAction.annotations).toEqual({
51
+ destructiveHint: true,
52
+ readOnlyHint: false,
53
+ });
54
+ expect(ticketAction.needsApproval).toBe(true);
55
+ expect(
56
+ await ticketAction.run({ customer: "acme", summary: "Login" }, context)
57
+ ).toEqual({ id: "acme:Login" });
58
+ });
59
+
60
+ it("rejects an action whose declared effect conflicts with its annotations", () => {
61
+ expect(() =>
62
+ defineAction({
63
+ id: "action-conflict",
64
+ name: "Conflicting Action",
65
+ description: "An invalid action.",
66
+ schema: { type: "object", properties: {} },
67
+ effect: "read",
68
+ annotations: { destructiveHint: true },
69
+ run: async () => null,
70
+ })
71
+ ).toThrow(/destructiveHint/);
72
+ });
73
+
74
+ it("lowers to a Core-compatible governed inline tool manifest", () => {
75
+ const manifest = ticketAction.toManifest({
76
+ id: "com.example.support",
77
+ version: "1.0.0",
78
+ grants: ["storage:kv"],
79
+ });
80
+
81
+ expect(() => PluginManifestSchema.parse(manifest)).not.toThrow();
82
+ expect(manifest.permission_grants).toEqual(["storage:kv", "tool:execute"]);
83
+ expect(manifest.runnables).toHaveLength(1);
84
+ expect(manifest.runnables[0]).toMatchObject({
85
+ id: "action-create-ticket",
86
+ name: "Create Ticket",
87
+ kind: "tool",
88
+ });
89
+ expect(manifest.runnables[0]?.config).toMatchObject({
90
+ slug: "action-create-ticket",
91
+ backend: "inline_deno",
92
+ action: true,
93
+ description: "Create a support ticket for a customer.",
94
+ input_schema: ticketAction.schema,
95
+ output_schema: ticketAction.outputSchema,
96
+ annotations: ticketAction.annotations,
97
+ needs_approval: true,
98
+ });
99
+ });
100
+
101
+ it("adapts the same implementation to the SDK MCP server", async () => {
102
+ const mcpTool = ticketAction.toMcpTool(context);
103
+
104
+ expect(mcpTool.name).toBe("action-create-ticket");
105
+ expect(mcpTool.description).toBe("Create a support ticket for a customer.");
106
+ expect(mcpTool.inputSchema).toEqual(ticketAction.schema);
107
+ expect(await mcpTool.run({ customer: "acme", summary: "MCP" })).toEqual({
108
+ id: "acme:MCP",
109
+ });
110
+ });
111
+
112
+ it("allows explicit read-only actions without approval", () => {
113
+ const action = defineAction({
114
+ id: "action-find-ticket",
115
+ name: "Find Ticket",
116
+ description: "Find a ticket by id.",
117
+ schema: { type: "object", properties: { id: { type: "string" } } },
118
+ effect: "read",
119
+ run: async ({ id }) => ({ id }),
120
+ });
121
+
122
+ expect(action.annotations).toEqual({
123
+ readOnlyHint: true,
124
+ destructiveHint: false,
125
+ });
126
+ expect(action.needsApproval).toBe(false);
127
+ });
128
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Canonical Ryu Action authoring API.
3
+ *
4
+ * An Action is the semantic contract for a business operation: one description,
5
+ * input schema, output schema, implementation, and effect declaration. It lowers
6
+ * to Ryu's existing governed `inline_deno` Tool backend, so this adds a coherent
7
+ * authoring seam without introducing a second Core execution runtime.
8
+ */
9
+
10
+ import {
11
+ type PluginManifest,
12
+ PluginManifestSchema,
13
+ type RunnableMeta,
14
+ type Surface,
15
+ } from "../manifest.ts";
16
+ import type { SdkRunnable } from "../mcp/server.ts";
17
+ import type { RunnableContext } from "./runnable-types.ts";
18
+ import {
19
+ defineTool,
20
+ type InlineToolManifestOptions,
21
+ inlineToolRunnable,
22
+ type ToolRunnable,
23
+ type ToolSchema,
24
+ } from "./tool.ts";
25
+
26
+ /** The two effects Core can enforce from tool annotations. */
27
+ export type ActionEffect = "mutate" | "read";
28
+
29
+ /** MCP-compatible effect hints plus any future provider-neutral boolean hints. */
30
+ export interface ActionAnnotations {
31
+ destructiveHint?: boolean;
32
+ idempotentHint?: boolean;
33
+ openWorldHint?: boolean;
34
+ readOnlyHint?: boolean;
35
+ [key: string]: boolean | undefined;
36
+ }
37
+
38
+ /** Options for defining a canonical Action. */
39
+ export interface ActionOptions<
40
+ TInput extends Record<string, unknown>,
41
+ TOutput,
42
+ > {
43
+ /** Additional MCP-compatible effect hints. */
44
+ annotations?: ActionAnnotations;
45
+ /** Description the agent reads when deciding whether to call the action. */
46
+ description: string;
47
+ /** Explicit effect used by read-only and approval enforcement. */
48
+ effect: ActionEffect;
49
+ /** Stable action id, also used as the generated Core tool slug. */
50
+ id: string;
51
+ /** Human-readable action name. */
52
+ name: string;
53
+ /** Require human approval even when global smart mode would not classify it. */
54
+ needsApproval?: boolean;
55
+ /** JSON Schema for the structured action result. */
56
+ outputSchema?: Record<string, unknown>;
57
+ /** The one implementation used by local callers and the packaged tool body. */
58
+ run(input: TInput, ctx: RunnableContext): Promise<TOutput>;
59
+ /** JSON Schema for all action inputs. */
60
+ schema: ToolSchema;
61
+ }
62
+
63
+ /** Options for lowering one Action into a standalone Ryu plugin manifest. */
64
+ export interface ActionManifestOptions {
65
+ /** Plugin activation events; defaults to eager activation. */
66
+ activationEvents?: readonly string[];
67
+ /** Extra Gateway grants required by the action body. */
68
+ grants?: readonly string[];
69
+ /** Reverse-domain plugin id (for example `com.acme.support`). */
70
+ id: string;
71
+ /** Display name; defaults to the Action name. */
72
+ name?: string;
73
+ /** Host surfaces this plugin targets. */
74
+ targets?: readonly Surface[];
75
+ /** Plugin semver. */
76
+ version: string;
77
+ }
78
+
79
+ /** A ToolRunnable with Action semantics and manifest/MCP adapters. */
80
+ export interface ActionRunnable<
81
+ TInput extends Record<string, unknown> = Record<string, unknown>,
82
+ TOutput = unknown,
83
+ > extends ToolRunnable<TInput, TOutput> {
84
+ /** Discriminates this semantic contract from an ordinary ToolRunnable. */
85
+ readonly action: true;
86
+ /** Effect hints lowered into Core's existing tool metadata. */
87
+ readonly annotations: ActionAnnotations;
88
+ /** Required action description. */
89
+ readonly description: string;
90
+ /** Declared effect. */
91
+ readonly effect: ActionEffect;
92
+ /** Whether Core must queue approval before execution. */
93
+ readonly needsApproval: boolean;
94
+ /** Structured result schema, when the action returns one. */
95
+ readonly outputSchema?: Record<string, unknown>;
96
+ /** Lower this Action to a validated, installable plugin manifest. */
97
+ toManifest(options: ActionManifestOptions): PluginManifest;
98
+ /** Adapt this Action to the SDK MCP server using the same implementation. */
99
+ toMcpTool(context: RunnableContext): SdkRunnable;
100
+ }
101
+
102
+ function deriveAnnotations(
103
+ effect: ActionEffect,
104
+ annotations: ActionAnnotations | undefined
105
+ ): ActionAnnotations {
106
+ const resolved = {
107
+ ...annotations,
108
+ readOnlyHint: annotations?.readOnlyHint ?? effect === "read",
109
+ destructiveHint: annotations?.destructiveHint ?? effect === "mutate",
110
+ };
111
+
112
+ if (effect === "read" && resolved.destructiveHint) {
113
+ throw new Error(
114
+ "[ryu-sdk] read actions cannot set annotations.destructiveHint=true"
115
+ );
116
+ }
117
+ if (effect === "mutate" && resolved.readOnlyHint) {
118
+ throw new Error(
119
+ "[ryu-sdk] mutate actions cannot set annotations.readOnlyHint=true"
120
+ );
121
+ }
122
+
123
+ return resolved;
124
+ }
125
+
126
+ function actionManifestEntry(action: ActionRunnable): RunnableMeta {
127
+ const options: InlineToolManifestOptions = {
128
+ action: true,
129
+ annotations: action.annotations,
130
+ description: action.description,
131
+ needsApproval: action.needsApproval,
132
+ ...(action.outputSchema ? { outputSchema: action.outputSchema } : {}),
133
+ };
134
+ return inlineToolRunnable(action, options);
135
+ }
136
+
137
+ function actionToManifest(
138
+ action: ActionRunnable,
139
+ options: ActionManifestOptions
140
+ ): PluginManifest {
141
+ const grants = [...new Set([...(options.grants ?? []), "tool:execute"])];
142
+ const raw = {
143
+ id: options.id,
144
+ name: options.name ?? action.name,
145
+ version: options.version,
146
+ runnables: [actionManifestEntry(action)],
147
+ permission_grants: grants,
148
+ activation_events: [...(options.activationEvents ?? ["*"])],
149
+ targets: [...(options.targets ?? [])],
150
+ };
151
+ const result = PluginManifestSchema.safeParse(raw);
152
+ if (!result.success) {
153
+ const first = result.error.issues[0];
154
+ const field = first?.path.join(".") ?? "unknown";
155
+ const message = first?.message ?? "validation failed";
156
+ throw new Error(
157
+ `[ryu-sdk] action manifest validation failed at '${field}': ${message}`
158
+ );
159
+ }
160
+ return result.data;
161
+ }
162
+
163
+ /**
164
+ * Define one business operation that can be run locally, packaged as a Core
165
+ * tool, or registered on an SDK MCP server without rewriting its implementation.
166
+ */
167
+ export function defineAction<
168
+ TInput extends Record<string, unknown> = Record<string, unknown>,
169
+ TOutput = unknown,
170
+ >(options: ActionOptions<TInput, TOutput>): ActionRunnable<TInput, TOutput> {
171
+ const annotations = deriveAnnotations(options.effect, options.annotations);
172
+ const tool = defineTool({
173
+ description: options.description,
174
+ id: options.id,
175
+ name: options.name,
176
+ run: options.run,
177
+ schema: options.schema,
178
+ });
179
+
180
+ const action: ActionRunnable<TInput, TOutput> = {
181
+ ...tool,
182
+ action: true,
183
+ annotations,
184
+ description: options.description,
185
+ effect: options.effect,
186
+ needsApproval: options.needsApproval ?? false,
187
+ ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
188
+ toManifest(manifestOptions) {
189
+ return actionToManifest(action, manifestOptions);
190
+ },
191
+ toMcpTool(context) {
192
+ return {
193
+ name: action.id,
194
+ description: action.description,
195
+ inputSchema: action.schema,
196
+ run: (input) => action.run(input as TInput, context),
197
+ };
198
+ },
199
+ };
200
+
201
+ return action;
202
+ }
@@ -9,6 +9,7 @@ function checklistApp(grants?: string[]) {
9
9
  slug: "checklist",
10
10
  title: "Checklist",
11
11
  version: "1.0.0",
12
+ uiEntry: "src/checklist.tsx",
12
13
  ...(grants ? { grants } : {}),
13
14
  tools: [{ name: "render", description: "Render a checklist" }],
14
15
  });
@@ -48,6 +49,7 @@ describe("defineApp widget grant", () => {
48
49
  slug: "tools",
49
50
  title: "Tools",
50
51
  version: "1.0.0",
52
+ uiEntry: "src/tools.tsx",
51
53
  tools: [{ name: "toggle", description: "Toggle", accessible: true }],
52
54
  });
53
55
  expect(manifest.contributes?.widgets ?? []).toHaveLength(0);
@@ -14,11 +14,13 @@
14
14
  * - A **companion** tool (`accessible:true`) is a call target a mounted widget
15
15
  * may invoke: it carries `widget_accessible:true` and gets no widget template.
16
16
  *
17
- * v1 boundary: this is **declarative pass-through only** there is no `run`
18
- * handler. Third-party tool code execution needs the plugin runtime (out of
19
- * scope); the widget renders from `window.openai.toolInput`/`toolOutput` and Core
20
- * echoes the validated arguments as `structuredContent`. `ryu pack` bundles the
21
- * `uiEntry` source into the manifest's `ui_code`.
17
+ * A tool may also attach a `ToolRunnable`. When present, its self-contained run
18
+ * body is emitted as Core's grant-gated `inline_deno` backend, so the widget and
19
+ * the behavior ship in one bundle. Without a runnable, the tool remains the
20
+ * declarative pass-through form: the widget renders from
21
+ * `window.openai.toolInput`/`toolOutput` and Core echoes the validated arguments
22
+ * as `structuredContent`. `ryu pack` bundles the `uiEntry` source into the
23
+ * manifest's `ui_code`.
22
24
  */
23
25
 
24
26
  import type {
@@ -26,11 +28,13 @@ import type {
26
28
  PluginManifest,
27
29
  Requires,
28
30
  RunnableMeta,
31
+ SlashCommandContribution,
29
32
  Surface,
30
33
  ToolAppConfig,
31
34
  WidgetContribution,
32
35
  } from "../manifest.ts";
33
36
  import { PluginManifestSchema } from "../manifest.ts";
37
+ import { inlineToolRunnable, type ToolRunnable } from "./tool.ts";
34
38
 
35
39
  /** The default widget MIME dialect (mirrors Core `default_widget_mime`). */
36
40
  const DEFAULT_APP_WIDGET_MIME = "text/html+skybridge";
@@ -59,6 +63,21 @@ function withWidgetRenderGrant(
59
63
  return out;
60
64
  }
61
65
 
66
+ /** Add the grant required for any executable inline tool body. */
67
+ function withToolExecuteGrant(
68
+ grants: readonly string[],
69
+ tools: readonly AppToolSpec[]
70
+ ): string[] {
71
+ const out = [...grants];
72
+ if (
73
+ tools.some((tool) => tool.runnable !== undefined) &&
74
+ !out.includes("tool:execute")
75
+ ) {
76
+ out.push("tool:execute");
77
+ }
78
+ return out;
79
+ }
80
+
62
81
  /** One tool a Ryu App declares. */
63
82
  export interface AppToolSpec {
64
83
  /**
@@ -74,8 +93,15 @@ export interface AppToolSpec {
74
93
  invoked?: string;
75
94
  /** Status label shown while a render tool runs (e.g. `"Plotting chart…"`). */
76
95
  invoking?: string;
77
- /** Tool name (unqualified). The wire id is `<server>__<name>`. */
96
+ /** Tool name (unqualified). The wire id is `<server>.<name>`. */
78
97
  name: string;
98
+ /**
99
+ * Optional executable body for this tool. Core receives it as an
100
+ * `inline_deno` runnable and runs it in the deny-by-default tool sandbox.
101
+ * The body must be self-contained; use the injected `host` capability surface
102
+ * for governed model calls and other platform primitives.
103
+ */
104
+ runnable?: Pick<ToolRunnable, "code" | "id" | "name" | "schema">;
79
105
  }
80
106
 
81
107
  /**
@@ -116,6 +142,8 @@ export interface DefineAppOptions {
116
142
  requires?: DefineAppRequires;
117
143
  /** MCP server namespace for the tool ids. Defaults to `slug`. */
118
144
  server?: string;
145
+ /** Slash commands this app exposes in the chat composer. */
146
+ slashCommands?: SlashCommandContribution[];
119
147
  /**
120
148
  * App slug — used to build the widget uri (`ui://widget/<slug>.html`) and, when
121
149
  * `server` is omitted, the MCP server namespace that qualifies each tool id.
@@ -141,7 +169,7 @@ export interface DefineAppOptions {
141
169
 
142
170
  /** Build a fully-qualified tool id from a server namespace and tool name. */
143
171
  export function appToolId(server: string, name: string): string {
144
- return `${server}__${name}`;
172
+ return `${server}.${name}`;
145
173
  }
146
174
 
147
175
  /**
@@ -182,8 +210,12 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
182
210
  for (const spec of options.tools) {
183
211
  const isRender = spec.accessible !== true;
184
212
  const id = appToolId(server, spec.name);
213
+ const executable = spec.runnable
214
+ ? inlineToolRunnable(spec.runnable)
215
+ : undefined;
185
216
 
186
- const config: ToolAppConfig = {
217
+ const config: ToolAppConfig & Record<string, unknown> = {
218
+ ...(executable?.config ?? {}),
187
219
  slug: id,
188
220
  description: spec.description,
189
221
  widget: isRender,
@@ -213,16 +245,18 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
213
245
 
214
246
  const contributes: Contributes = {
215
247
  turn_hooks: [],
248
+ chat_features: [],
216
249
  // This builder synthesises an app from its runnables; an app that emits
217
250
  // events declares them in a hand-authored `manifest.json`, same as
218
251
  // `lsp_servers` below.
219
252
  hook_events: [],
220
253
  composer_controls: [],
221
254
  settings_tabs: [],
222
- slash_commands: [],
255
+ slash_commands: options.slashCommands ?? [],
223
256
  sidebar_sections: [],
224
257
  sidebar_buttons: [],
225
258
  dock_panels: [],
259
+ live_activities: [],
226
260
  // Empty for the same reason as every sibling family above: this builder
227
261
  // synthesises `widgets` from the app's own runnables and nothing else, and
228
262
  // takes no `contributes` passthrough. An app that wants to declare language
@@ -235,6 +269,8 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
235
269
  data_categories: [],
236
270
  pi_extensions: [],
237
271
  output_styles: [],
272
+ message_actions: [],
273
+ selection_actions: [],
238
274
  widgets,
239
275
  };
240
276
 
@@ -249,15 +285,15 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
249
285
  // Core gates widget promotion on declared-AND-enabled-AND-granted, and a
250
286
  // missing grant fails as `DeniedNoGrant` — which is an `info!` log and
251
287
  // nothing else. The widget silently renders as plain text, with no error
252
- // in the UI and nothing pointing at the manifest. Every app scaffolded
253
- // through `defineApp` hit that, because the only fix was a grant string
254
- // the templates never mention and the builder never added; the one
255
- // working example on disk hand-writes it.
288
+ // in the UI and nothing pointing at the manifest.
256
289
  //
257
290
  // Added only when there is a widget to render, and unioned rather than
258
291
  // overwritten so an author's own `grants` list survives and re-declaring
259
292
  // it is not an error.
260
- permission_grants: withWidgetRenderGrant(options.grants ?? [], widgets),
293
+ permission_grants: withToolExecuteGrant(
294
+ withWidgetRenderGrant(options.grants ?? [], widgets),
295
+ options.tools
296
+ ),
261
297
  activation_events: options.activationEvents ?? ["*"],
262
298
  contributes,
263
299
  // `targets: []` means EVERY surface, so an app that declares none is