@nylorun/agents 0.3.0-beta → 0.5.0-beta

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # @nylorun/agents
2
2
 
3
+ ## 0.5.0-beta
4
+
5
+ ### Minor Changes
6
+
7
+ - 1cd7dc7: Add subagents: put an agent in another agent's `tools` (`Agent({ tools: [lookupOrder, researcher] })` or `.use({ tools: [researcher] })`) and the model can delegate to it. The tool is named after the agent's id, takes `{ task: string }`, and its description is the agent's `description`, which is now required for an agent used as a tool. The engine runs the child inside the parent's turn as a durable branch: fresh context, its own tools and hooks served by the root agent's executor, its own MCP servers, the session's sandbox, and only its final output (or `outputSchema` result) returned. Empty output, failures (with partial output marked as evidence), and requests for input or approval inside a child reach the parent as failed tool results. Parallel delegation calls run concurrently, completed child work is never re-run on replay, and cancelling the session cancels every child.
8
+
9
+ v1 is one level deep and non-interactive. Nested delegation, child tools that declare `approval`, and differing sandboxes across the tree fail the build with a named diagnostic. The manifest adds an optional `agent` body on a tool (schema version unchanged), actions and effects carry `agent: { id, path, delegationId }`, tool context gains `ctx.agent`, the durable host resolves a new `delegation` effect kind, and the Runtime emits `delegation.started` / `delegation.completed` events and filters history with `?agent=` (`session.history({ agent })`). Studio shows delegations, labels child actions with their agent, and filters events by agent.
10
+
11
+ ### Patch Changes
12
+
13
+ - Pin core to the tested release.
14
+ - Updated dependencies [1cd7dc7]
15
+ - @nylorun/core@0.4.0-beta
16
+
17
+ ## 0.4.0-beta
18
+
19
+ ### Minor Changes
20
+
21
+ - b8d822a: Add `mcp(...)` to declare MCP servers as one capability for `Agent.use`, matching agent-plugins / manifest v3 `mcpServers` shape.
22
+ - b8d822a: Add `skills(path)` to load an agentskills.io catalog folder into `Agent.use` without duplicating `skills` and `skillRecords`. Parse `SKILL.md` frontmatter with `gray-matter`.
23
+ - b8d822a: Add `sandbox()`: one `.use(sandbox())` gives an agent Runtime-executed `bash`, `read`, `write`, `edit`, `grep` and `glob` tools on an isolated machine with a persistent `/workspace`. The Runtime selects a microsandbox microVM where available, otherwise an in-process virtual shell, enforces deny-by-default egress presets, owns sandbox lifecycle, and reports its choice through `GET /v1/host/sandbox`, the `nylorun dev` banner and `nylorun doctor sandbox`.
24
+ - b8d822a: Breaking beta: replace `beforeModelCall` / `afterModelCall` with scoped hooks. Register `before("turn" | "step", fn)` and `after("step" | "turn", fn)` on the agent, or `before: { turn, step }` / `after: { step, turn }` on a capability. `before("turn")` runs once per turn and its `Patch` applies to every model call in the turn; the new `after("turn")` returns a `TurnDecision` for the final answer. `after` hooks take one argument and receive `attempt`, and `retry` now retries instead of failing the run. The manifest moves to `manifestSchemaVersion: 4` with `capabilities[].hooks`, and `BeforeModelCallFn`, `AfterModelCallFn` and the `beforeModelCall` / `afterModelCall` action kinds are removed. Every capability registered at a hook point now runs in one `hook` executor action, an expired hook claim is offered again instead of becoming uncertain, and the durable engine version is `hosted-2`. Hook toggles now hide a capability's tools, or one tool of a multi-tool capability, instead of having no effect or failing. Studio lists each capability's hooks with how often they run and labels hook actions. See MIGRATION.md.
25
+
26
+ ### Patch Changes
27
+
28
+ - Pin core to the tested release.
29
+ - Updated dependencies [b8d822a]
30
+ - Updated dependencies [b8d822a]
31
+ - Updated dependencies [b8d822a]
32
+ - Updated dependencies [b8d822a]
33
+ - @nylorun/core@0.3.0-beta
34
+
3
35
  ## 0.3.0-beta
4
36
 
5
37
  ### Minor Changes
package/README.md CHANGED
@@ -49,12 +49,98 @@ await connection.ready;
49
49
  await connection.close();
50
50
  ```
51
51
 
52
+ Load Agent Skills from a local catalog folder with `skills(path)`:
53
+
54
+ ```ts
55
+ import { Agent, skills } from "@nylorun/agents";
56
+
57
+ const assistant = Agent({
58
+ id: "assistant",
59
+ name: "Order assistant",
60
+ instructions: "Use lookup_order for orders.",
61
+ tools: [lookupOrder],
62
+ }).use(skills("./assistant-skills"));
63
+ ```
64
+
65
+ Each subdirectory under the catalog must contain a `SKILL.md` with YAML frontmatter (`name`, `description`) per [Agent Skills](https://agentskills.io/home). Supporting files (for example `references/`) are available through `read_skill_resource` after `load_skill`. The helper sets both the manifest skill catalog and the on-disk skill records so you do not duplicate content.
66
+
67
+ Declare MCP servers with `mcp(...)` (same map shape as agent-plugins `mcpServers` / manifest v3):
68
+
69
+ ```ts
70
+ import { Agent, mcp } from "@nylorun/agents";
71
+
72
+ const assistant = Agent({
73
+ id: "assistant",
74
+ name: "Assistant",
75
+ instructions: "Use the available tools.",
76
+ }).use(
77
+ mcp({
78
+ github: {
79
+ name: "github",
80
+ type: "streamable-http",
81
+ url: "https://mcp.example.com/github",
82
+ },
83
+ })
84
+ );
85
+ ```
86
+
87
+ Each key must equal that server's `name`. Transports follow [Agent Plugins MCP servers](https://agent-plugins.org/plugin-authors/mcp-servers): `stdio`, `streamable-http`, and `sse`. Pass `{ id: "…" }` as the second argument to override the default capability id `"mcp"`.
88
+
89
+ Give an agent an isolated computer with `sandbox()`:
90
+
91
+ ```ts
92
+ import { Agent, sandbox } from "@nylorun/agents";
93
+
94
+ const analyst = Agent({
95
+ id: "analyst",
96
+ instructions: "Analyse the data the user gives you. Use Python.",
97
+ }).use(sandbox());
98
+ ```
99
+
100
+ The model gets `bash`, `read`, `write`, `edit`, `grep` and `glob` on a Linux machine with a persistent `/workspace`. These tools run in the Runtime, not in your process, so sandbox-only agents need no connected executor. The agent declares what it needs; the Runtime decides where it runs (a microsandbox VM where the machine supports it, otherwise an emulated shell). Every option is optional plain data:
101
+
102
+ ```ts
103
+ .use(sandbox({
104
+ image: "python:3.13", // any OCI image; default python:3.13-slim
105
+ network: { preset: "dev", allow: ["api.example.com"] }, // "none" | "dev" (default) | "open"
106
+ resources: { cpus: 2, memory: "2GiB" },
107
+ idle: "15m", // stop compute when idle; files persist
108
+ }))
109
+ ```
110
+
111
+ The `dev` preset allows package registries and code hosts. Private networks, loopback, the host and cloud metadata endpoints are always blocked. Options from the full design that are not in this version (`setup`, `files`, `secrets`, `mount`, `onStart`, `scope`, ...) throw a `SandboxError` that says so. See [the sandbox design](../docs/design/sandboxes.md).
112
+
113
+ Put an agent in another agent's `tools` to let the model delegate to it:
114
+
115
+ ```ts
116
+ import { Agent } from "@nylorun/agents";
117
+ import { z } from "zod";
118
+
119
+ const researcher = Agent({
120
+ id: "researcher",
121
+ description: "Investigates an order's history. Returns a short summary with the ids it relied on.",
122
+ instructions: "Investigate one question about one order. Be exhaustive, then be brief.",
123
+ tools: [searchOrders, readTicket],
124
+ outputSchema: z.object({ summary: z.string(), evidence: z.array(z.string()) }),
125
+ });
126
+
127
+ const support = Agent({
128
+ id: "support",
129
+ instructions: "For anything needing more than two lookups, delegate to researcher with a complete, self-contained task.",
130
+ tools: [lookupOrder, refundOrder, researcher],
131
+ });
132
+ ```
133
+
134
+ The tool is named after the agent's `id` and takes `{ task: string }`; the agent's `description` (required) is what the parent's model reads to decide when to delegate. The child starts with a fresh context: it sees its own instructions and the task, nothing of the parent's conversation, and only its final text (or `outputSchema` result) comes back. It keeps its own tools, hooks, skills and MCP servers, served by the executor you already run for the parent (`connectAgents({ agents: [support] })` serves both), shares the session's sandbox, and starts with empty `ctx.state`. Tools can read `ctx.agent` (`{ id, path, delegationId }`). Several delegation calls in one model response run in parallel. An empty answer, a failure (with the child's last text marked as evidence) or a cancelled child reaches the parent's model as a failed tool result, never as success. The Runtime emits `delegation.started` and `delegation.completed`; `session.history({ agent })` filters by `delegationId` (one child invocation) or by path such as `support/researcher` (every concurrent child that shares that path).
135
+
136
+ Delegate when the parent should keep the answer. When a specialist should own the rest of the conversation, switch capabilities with a `before("step")` patch instead. This version is one level deep and non-interactive: a delegated agent cannot use agents as tools, its tools cannot declare `approval` (keep those on the parent), and `ctx.ask`, `ctx.approve`, `ctx.sleep` or `ctx.waitFor` inside it fail with `delegation.interaction-unsupported`. Delegation is not an approval boundary; approvals live on tools.
137
+
52
138
  Use `session.observe({ cursor, signal })` for resumable canonical events, `session.inspect()` for waiting/uncertain state, and `approve`, `respond`, or `cancel` with an explicit stable idempotency key. Retry the same semantic command with the same key. `ownerUserId` must come from trusted server authentication. Application credentials are not browser credentials; browser applications need an authorized backend. Definition authoring is browser-bundleable.
53
139
 
54
140
  The Runtime destination is explicit, or defaults from `NYLORUN_RUNTIME_URL`. Application and executor keys default from `NYLORUN_SERVER_KEY` and `NYLORUN_EXECUTOR_KEY`. Implementation version defaults from `NYLORUN_IMPLEMENTATION_VERSION`, then `dev`. A host must provision executor scope for the agent id. Agents do not hash the manifest, and an in-flight action stays claimable after the registered digest changes. Model selection and model credentials belong to the Runtime.
55
141
 
56
142
  `connectAgents({ agents })` opens authenticated fetch SSE before discovering actions, rediscovers after reconnection, and claims pending actions for a connected agent id. It renews leases while executing, then retries HTTP result delivery with the same recorded outcome and idempotency key. Reconnect delay is bounded at 30 seconds. Notifications confer no execution authority. There is no periodic action-discovery polling. Close aborts the stream, HTTP requests and lease timers and signals running functions; JavaScript cannot forcibly terminate a function that ignores its signal. The host makes expired in-flight actions uncertain instead of automatically repeating external effects.
57
143
 
58
- Tools receive state, info, identity, resume, signal, approval/response helpers and memoized `step`. Step outcomes survive a persisted wait result; they do not establish exactly-once external effects after an unacknowledged crash. `sleep` and `waitFor` currently return inspectable deferred outcomes; automatic timer/event wakeups remain runtime implementation work. Remote `onModelCall` convenience and progress-event transport are not supplied in this pass. Arbitrary middleware closures are rejected for durable definitions; use before/after hooks. Agent definitions have no `.run()`; explicit local execution is available through `@nylorun/harness/run`.
144
+ Tools receive state, info, identity, resume, signal, approval/response helpers and memoized `step`. Step outcomes survive a persisted wait result; they do not establish exactly-once external effects after an unacknowledged crash. `sleep` and `waitFor` currently return inspectable deferred outcomes; automatic timer/event wakeups remain runtime implementation work. Remote `onModelCall` convenience and progress-event transport are not supplied in this pass. Arbitrary middleware closures are rejected for durable definitions; use `before`/`after` hooks. The executor runs every capability registered at a hook point in one action. Agent definitions have no `.run()`; explicit local execution is available through `@nylorun/harness/run`.
59
145
 
60
- The SDK depends only on core within the Nylorun packages; installing it does not install harness. Use `/define`, `/client`, or `/executor` for focused imports, or the root for convenience. Studio uses `/client`. See [the adopted host contract](../harness/HOST_CONTRACT.md) and the repository handoff for the verification boundary.
146
+ The SDK depends only on core within the Nylorun packages; installing it does not install harness. Use `/define`, `/client`, or `/executor` for focused imports, or the root for convenience. Studio uses `/client`. See [the adopted host contract](../harness/HOST_CONTRACT.md).
package/dist/client.d.ts CHANGED
@@ -153,8 +153,10 @@ export declare class SessionClient {
153
153
  cursor: string | null;
154
154
  requestId: string;
155
155
  }>;
156
+ /** `agent` narrows to one agent used as a tool: its delegationId or its path. */
156
157
  history(options?: {
157
158
  cursor?: string;
159
+ agent?: string;
158
160
  signal?: AbortSignal;
159
161
  }): Promise<{
160
162
  items: {
package/dist/client.js CHANGED
@@ -1,21 +1,30 @@
1
+ import { delegateOf } from "@nylorun/core/define";
1
2
  import { LiveEventSchema, SessionItemsResponseSchema, } from "@nylorun/core/contracts";
2
3
  import { Transport, id, segment } from "./http.js";
3
4
  import { observeSSE } from "./sse.js";
4
5
  const middlewareClosure = "Hosted definitions support declarative capabilities and before/after hooks, not middleware closures";
5
- function pluginRootsOf(agent) {
6
+ /** The agent's declarations, then those of each agent it uses as a tool, keyed `<child>/<capability>`. */
7
+ function declarationsOf(agent) {
6
8
  const built = agent.build?.() ?? agent;
7
- const declarations = built.getBinding?.().declarations ?? [];
9
+ const binding = built.getBinding?.();
10
+ const found = (binding?.declarations ?? []).map((item) => ({ key: item.id, item }));
11
+ for (const tool of binding?.tools ?? []) {
12
+ const child = delegateOf(tool)?.agent;
13
+ for (const item of child?.getBinding().declarations ?? [])
14
+ found.push({ key: `${child.id}/${item.id}`, item });
15
+ }
16
+ return found;
17
+ }
18
+ function pluginRootsOf(agent) {
8
19
  const roots = {};
9
- for (const item of declarations) {
20
+ for (const { key, item } of declarationsOf(agent))
10
21
  if (item.pluginRoot)
11
- roots[item.id] = item.pluginRoot;
12
- }
22
+ roots[key] = item.pluginRoot;
13
23
  return Object.keys(roots).length === 0 ? undefined : roots;
14
24
  }
15
25
  /** Closures stay on the live binding. The published manifest cannot name them. */
16
26
  export function assertNoMiddlewareClosures(agent) {
17
- const built = agent.build?.() ?? agent;
18
- if (built.getBinding?.().declarations.some((item) => item.hasMiddleware))
27
+ if (declarationsOf(agent).some(({ item }) => item.hasMiddleware))
19
28
  throw new Error(middlewareClosure);
20
29
  }
21
30
  export class AgentsClient {
@@ -152,8 +161,15 @@ export class SessionClient {
152
161
  ...(options.reason ? { reason: options.reason } : {}),
153
162
  }, options.signal);
154
163
  }
164
+ /** `agent` narrows to one agent used as a tool: its delegationId or its path. */
155
165
  async history(options = {}) {
156
- return SessionItemsResponseSchema.parse(await this.transport.json(`${this.path}/items${options.cursor ? `?cursor=${encodeURIComponent(options.cursor)}` : ""}`, "GET", undefined, options.signal));
166
+ const query = new URLSearchParams();
167
+ if (options.cursor)
168
+ query.set("cursor", options.cursor);
169
+ if (options.agent)
170
+ query.set("agent", options.agent);
171
+ const search = query.toString();
172
+ return SessionItemsResponseSchema.parse(await this.transport.json(`${this.path}/items${search ? `?${search}` : ""}`, "GET", undefined, options.signal));
157
173
  }
158
174
  async *observe(options = {}) {
159
175
  for await (const frame of observeSSE(this.transport, `${this.path}/events`, options)) {
@@ -1,4 +1,4 @@
1
1
  import { type BuiltAgent } from "@nylorun/core/define";
2
2
  import type { Action, ActionOutcome } from "@nylorun/core/contracts";
3
3
  /** Executes code only after a host-issued claim. No engine dependency. */
4
- export declare function executeAction(action: Action, agent: BuiltAgent, signal: AbortSignal): Promise<ActionOutcome>;
4
+ export declare function executeAction(action: Action, root: BuiltAgent, signal: AbortSignal): Promise<ActionOutcome>;
@@ -1,4 +1,4 @@
1
- import { implementationsFor, isToolError, normalizeToolDefinition, normalizedSchemasFor, } from "@nylorun/core/define";
1
+ import { delegateOf, implementationsFor, isToolError, normalizeToolDefinition, normalizedSchemasFor, runHookPoint, } from "@nylorun/core/define";
2
2
  class Suspend {
3
3
  outcome;
4
4
  constructor(outcome) {
@@ -9,33 +9,21 @@ const object = (value) => value !== null && typeof value === "object" && !Array.
9
9
  ? value
10
10
  : {};
11
11
  /** Executes code only after a host-issued claim. No engine dependency. */
12
- export async function executeAction(action, agent, signal) {
12
+ export async function executeAction(action, root, signal) {
13
+ // Work for an agent used as a tool runs that agent's code, served from the root's binding.
14
+ const agent = action.agent ? delegatedAgent(root, action.agent.id) : root;
15
+ const ref = action.agent ?? { id: root.id, path: root.id };
16
+ if (action.kind === "hook")
17
+ // All capabilities at this hook point run concurrently in one action. Never throws.
18
+ return {
19
+ value: {
20
+ results: await runHookPoint(implementationsFor(agent), action.hook, action.input),
21
+ },
22
+ };
13
23
  const impl = implementationsFor(agent)[action.capabilityId];
14
24
  if (!impl)
15
25
  throw new Error(`No capability ${action.capabilityId}`);
16
- if (action.kind === "beforeModelCall") {
17
- if (!impl.beforeModelCall)
18
- throw new Error("Missing beforeModelCall implementation");
19
- try {
20
- return { value: (await impl.beforeModelCall(action.input)) ?? {} };
21
- }
22
- catch (error) {
23
- return { value: { block: message(error) } };
24
- }
25
- }
26
- if (action.kind === "afterModelCall") {
27
- if (!impl.afterModelCall)
28
- throw new Error("Missing afterModelCall implementation");
29
- try {
30
- return {
31
- value: (await impl.afterModelCall(action.input, action.context)) ?? {},
32
- };
33
- }
34
- catch (error) {
35
- return { value: { block: message(error) } };
36
- }
37
- }
38
- const raw = impl.tools?.[action.toolName ?? ""];
26
+ const raw = impl.tools?.[action.toolName];
39
27
  if (!raw)
40
28
  throw new Error("Missing tool implementation");
41
29
  const tool = normalizeToolDefinition(raw);
@@ -92,6 +80,7 @@ export async function executeAction(action, agent, signal) {
92
80
  signal,
93
81
  info: ctx.info,
94
82
  session: { id: action.sessionId },
83
+ agent: ref,
95
84
  ...(ctx.resume ? { resume: ctx.resume } : {}),
96
85
  state: {
97
86
  get: (key) => state[key],
@@ -185,6 +174,14 @@ export async function executeAction(action, agent, signal) {
185
174
  };
186
175
  }
187
176
  }
177
+ function delegatedAgent(root, id) {
178
+ for (const capability of Object.values(implementationsFor(root))) {
179
+ const child = delegateOf(capability.tools?.[id])?.agent;
180
+ if (child)
181
+ return child;
182
+ }
183
+ throw new Error(`Agent '${root.id}' does not use '${id}' as a tool`);
184
+ }
188
185
  function message(error) {
189
186
  return error instanceof Error ? error.message : String(error);
190
187
  }
package/dist/executor.js CHANGED
@@ -47,7 +47,13 @@ export function connectAgents(options) {
47
47
  dirty = false;
48
48
  const response = await transport.json("/v1/actions", "GET", undefined, signal);
49
49
  for (const item of response.actions) {
50
- const action = ActionSchema.parse(item);
50
+ // Skip actions this SDK cannot run (e.g. from an older Runtime) instead of stalling discovery.
51
+ const parsed = ActionSchema.safeParse(item);
52
+ if (!parsed.success) {
53
+ report(parsed.error);
54
+ continue;
55
+ }
56
+ const action = parsed.data;
51
57
  const agent = agents.get(action.agentId);
52
58
  if (!agent || action.status !== "pending" || active.has(action.actionId))
53
59
  continue;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Agent, AgentBuilder, AgentBuildError, AgentLifecycleError, capability, tool, defineSchema, ToolError, } from "@nylorun/core/define";
2
- export type { AgentOptions, AgentManifest, BuiltAgent, ToolDefinition, ToolExecutionContext, ToolOutcome, Patch, Decision, JsonValue, JsonObject, } from "@nylorun/core/define";
2
+ export type { AgentOptions, AgentManifest, BuiltAgent, ToolDefinition, ToolExecutionContext, ToolOutcome, Patch, Decision, TurnDecision, HookScope, BeforeHook, AfterHook, JsonValue, JsonObject, } from "@nylorun/core/define";
3
3
  export type { LiveEvent, SessionCommand, Action, ActionOutcome, CredentialInfo, CredentialSelection, VaultInfo, } from "@nylorun/core/contracts";
4
4
  export { AgentsClient, SessionClient, createClient } from "./client.js";
5
5
  export type { AgentSource, SessionView, CommandOptions } from "./client.js";
@@ -13,3 +13,9 @@ export { loadPlugin, PluginError } from "./plugins/load.js";
13
13
  export type { LoadedPlugin, PluginDiagnostic } from "./plugins/load.js";
14
14
  export { prepareStdioLaunch, expandPluginPlaceholders } from "./plugins/launch.js";
15
15
  export type { StdioLaunch } from "./plugins/launch.js";
16
+ export { skills, loadSkillsFromDirectory, resolveSkillsRoot, parseSkill, SkillsError, formatSkillCatalog, SKILLS_USAGE, } from "./skills/index.js";
17
+ export type { SkillsCapability, SkillsOptions, SkillDiagnostic, } from "./skills/index.js";
18
+ export { mcp, McpError } from "./mcp/index.js";
19
+ export type { McpCapability, McpOptions } from "./mcp/index.js";
20
+ export { sandbox, SandboxError } from "./sandbox/index.js";
21
+ export type { SandboxCapability, SandboxCapabilityOptions, SandboxOptions, } from "./sandbox/index.js";
package/dist/index.js CHANGED
@@ -6,3 +6,6 @@ export { RuntimeError } from "./http.js";
6
6
  export { plugin } from "./plugins/plugin.js";
7
7
  export { loadPlugin, PluginError } from "./plugins/load.js";
8
8
  export { prepareStdioLaunch, expandPluginPlaceholders } from "./plugins/launch.js";
9
+ export { skills, loadSkillsFromDirectory, resolveSkillsRoot, parseSkill, SkillsError, formatSkillCatalog, SKILLS_USAGE, } from "./skills/index.js";
10
+ export { mcp, McpError } from "./mcp/index.js";
11
+ export { sandbox, SandboxError } from "./sandbox/index.js";
@@ -0,0 +1,2 @@
1
+ export { mcp, McpError } from "./mcp.js";
2
+ export type { McpCapability, McpOptions } from "./mcp.js";
@@ -0,0 +1 @@
1
+ export { mcp, McpError } from "./mcp.js";
@@ -0,0 +1,30 @@
1
+ import type { CapabilityDeclaration, McpServerManifest } from "@nylorun/core/define";
2
+ export interface McpOptions {
3
+ /** Capability id. Defaults to `"mcp"`. */
4
+ readonly id?: string;
5
+ }
6
+ export interface McpCapability extends CapabilityDeclaration {
7
+ readonly mcpServers: Readonly<Record<string, McpServerManifest>>;
8
+ }
9
+ export declare class McpError extends Error {
10
+ readonly code: string;
11
+ constructor(code: string, message: string);
12
+ }
13
+ /**
14
+ * Declare MCP servers as one capability for `Agent.use`.
15
+ *
16
+ * The map shape matches agent-plugins `mcpServers` and manifest v3: each key
17
+ * must equal that server's `name`. Supported transports are `stdio`,
18
+ * `streamable-http`, and `sse` (see https://agent-plugins.org/plugin-authors/mcp-servers).
19
+ *
20
+ * ```ts
21
+ * Agent({...}).use(mcp({
22
+ * github: {
23
+ * name: "github",
24
+ * type: "streamable-http",
25
+ * url: "https://mcp.example.com/github",
26
+ * },
27
+ * }))
28
+ * ```
29
+ */
30
+ export declare function mcp(servers: Readonly<Record<string, McpServerManifest>>, options?: McpOptions): McpCapability;
@@ -0,0 +1,80 @@
1
+ export class McpError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.name = "McpError";
6
+ this.code = code;
7
+ }
8
+ }
9
+ /**
10
+ * Declare MCP servers as one capability for `Agent.use`.
11
+ *
12
+ * The map shape matches agent-plugins `mcpServers` and manifest v3: each key
13
+ * must equal that server's `name`. Supported transports are `stdio`,
14
+ * `streamable-http`, and `sse` (see https://agent-plugins.org/plugin-authors/mcp-servers).
15
+ *
16
+ * ```ts
17
+ * Agent({...}).use(mcp({
18
+ * github: {
19
+ * name: "github",
20
+ * type: "streamable-http",
21
+ * url: "https://mcp.example.com/github",
22
+ * },
23
+ * }))
24
+ * ```
25
+ */
26
+ export function mcp(servers, options = {}) {
27
+ const id = options.id ?? "mcp";
28
+ if (!isRecord(servers)) {
29
+ throw new McpError("mcp.invalid", "mcp() expects a non-empty map of MCP servers");
30
+ }
31
+ const entries = Object.entries(servers);
32
+ if (entries.length === 0) {
33
+ throw new McpError("mcp.empty", "mcp() requires at least one MCP server");
34
+ }
35
+ const mcpServers = {};
36
+ for (const [key, server] of entries) {
37
+ if (!isMcpServer(server)) {
38
+ throw new McpError("mcp.invalid-server", `MCP server '${key}' is not a valid stdio, streamable-http, or sse declaration`);
39
+ }
40
+ if (server.name !== key) {
41
+ throw new McpError("mcp.name-mismatch", `MCP server key '${key}' must equal the server name '${server.name}'`);
42
+ }
43
+ mcpServers[key] = freezeServer(server);
44
+ }
45
+ return { id, mcpServers: Object.freeze(mcpServers) };
46
+ }
47
+ function freezeServer(server) {
48
+ if (server.type === "stdio") {
49
+ return Object.freeze({
50
+ name: server.name,
51
+ type: "stdio",
52
+ command: server.command,
53
+ ...(server.args === undefined ? {} : { args: Object.freeze([...server.args]) }),
54
+ ...(server.env === undefined
55
+ ? {}
56
+ : { env: Object.freeze({ ...server.env }) }),
57
+ ...(server.cwd === undefined ? {} : { cwd: server.cwd }),
58
+ });
59
+ }
60
+ return Object.freeze({
61
+ name: server.name,
62
+ type: server.type,
63
+ url: server.url,
64
+ ...(server.headers === undefined
65
+ ? {}
66
+ : { headers: Object.freeze({ ...server.headers }) }),
67
+ });
68
+ }
69
+ function isMcpServer(value) {
70
+ if (!isRecord(value) || typeof value.name !== "string" || !value.name)
71
+ return false;
72
+ if (value.type === "stdio")
73
+ return typeof value.command === "string" && value.command.length > 0;
74
+ if (value.type === "streamable-http" || value.type === "sse")
75
+ return typeof value.url === "string" && value.url.length > 0;
76
+ return false;
77
+ }
78
+ function isRecord(value) {
79
+ return !!value && typeof value === "object" && !Array.isArray(value);
80
+ }
@@ -1,5 +1,6 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, } from "node:fs";
2
2
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { loadSkillsFromDirectory } from "../skills/load.js";
3
4
  import { expandPluginPlaceholders } from "./launch.js";
4
5
  export const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
5
6
  export const MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
@@ -17,7 +18,6 @@ const PLUGIN_FIELDS = new Set([
17
18
  ]);
18
19
  const AUTHOR_FIELDS = new Set(["name", "email", "url"]);
19
20
  const RESERVED_ENV = new Set(["PLUGIN_ROOT", "PLUGIN_DATA"]);
20
- const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
21
21
  export class PluginError extends Error {
22
22
  code;
23
23
  diagnostics;
@@ -127,125 +127,10 @@ function loadSkills(root, diagnostics) {
127
127
  });
128
128
  return {};
129
129
  }
130
- const skills = {};
131
- for (const entry of readdirSync(real, { withFileTypes: true })) {
132
- if (!entry.isDirectory() || entry.name.startsWith("."))
133
- continue;
134
- const directory = join(real, entry.name);
135
- const directoryReal = realInside(root, directory);
136
- if (!directoryReal) {
137
- diagnostics.push({
138
- severity: "warning",
139
- code: "plugin.skill-skipped",
140
- message: `Skipped skill '${entry.name}' because its directory escapes the package`,
141
- path: directory,
142
- });
143
- continue;
144
- }
145
- const skillFile = join(directoryReal, "SKILL.md");
146
- if (!existsSync(skillFile))
147
- continue;
148
- const skillReal = realInside(root, skillFile);
149
- if (!skillReal || !statSync(skillReal).isFile()) {
150
- diagnostics.push({
151
- severity: "warning",
152
- code: "plugin.skill-skipped",
153
- message: `Skipped skill '${entry.name}' because SKILL.md escapes the package`,
154
- path: skillFile,
155
- });
156
- continue;
157
- }
158
- const parsed = parseSkill(readFileSync(skillReal, "utf8"), entry.name);
159
- if (!parsed) {
160
- diagnostics.push({
161
- severity: "warning",
162
- code: "plugin.skill-skipped",
163
- message: `Skipped skill '${entry.name}' because SKILL.md is not a valid Agent Skill`,
164
- path: skillFile,
165
- });
166
- continue;
167
- }
168
- if (skills[parsed.name]) {
169
- diagnostics.push({
170
- severity: "warning",
171
- code: "plugin.skill-skipped",
172
- message: `Skipped duplicate skill '${parsed.name}'`,
173
- path: skillFile,
174
- });
175
- continue;
176
- }
177
- const resources = readResources(root, directoryReal, diagnostics);
178
- skills[parsed.name] = {
179
- name: parsed.name,
180
- description: parsed.description,
181
- instructions: parsed.instructions,
182
- ...(Object.keys(resources).length === 0 ? {} : { resources }),
183
- };
184
- }
185
- return skills;
186
- }
187
- function parseSkill(text, directoryName) {
188
- const match = /^-{3}\r?\n([\s\S]*?)\r?\n-{3}(?:\r?\n|$)/.exec(text);
189
- if (!match)
190
- return undefined;
191
- const fields = {};
192
- for (const raw of (match[1] ?? "").split(/\r?\n/)) {
193
- if (!raw.trim() || raw.trimStart().startsWith("#"))
194
- continue;
195
- const line = /^([\w-]+)\s*:\s*(.*)$/.exec(raw);
196
- if (!line || line[1] === "metadata")
197
- continue;
198
- fields[line[1]] = unquote(line[2] ?? "");
199
- }
200
- const name = fields.name?.trim() ?? "";
201
- const description = fields.description?.trim() ?? "";
202
- if (!name || !description || description.length > 1024)
203
- return undefined;
204
- if (name.length > 64 || !SKILL_NAME.test(name) || name !== directoryName)
205
- return undefined;
206
- return { name, description, instructions: text.slice(match[0].length).replace(/^\r?\n/, "") };
207
- }
208
- function readResources(root, directory, diagnostics) {
209
- const resources = {};
210
- const walk = (current, depth) => {
211
- if (depth > 6)
212
- return;
213
- for (const entry of readdirSync(current, { withFileTypes: true })) {
214
- if (entry.name.startsWith(".") || entry.name === "node_modules")
215
- continue;
216
- const full = join(current, entry.name);
217
- if (entry.isDirectory()) {
218
- const real = realInside(root, full);
219
- if (!real)
220
- continue;
221
- walk(real, depth + 1);
222
- continue;
223
- }
224
- if (!entry.isFile() && !entry.isSymbolicLink())
225
- continue;
226
- if (depth === 0 && entry.name === "SKILL.md")
227
- continue;
228
- const real = realInside(root, full);
229
- if (!real || !statSync(real).isFile()) {
230
- diagnostics.push({
231
- severity: "warning",
232
- code: "plugin.resource-denied",
233
- message: `Denied resource outside the package: ${entry.name}`,
234
- path: full,
235
- });
236
- continue;
237
- }
238
- const bytes = readFileSync(real);
239
- if (bytes.includes(0))
240
- continue;
241
- const rel = relative(directory, real).split(sep).join("/");
242
- if (!rel || rel.startsWith(".."))
243
- continue;
244
- resources[rel] = bytes.toString("utf8");
245
- }
246
- };
247
- walk(directory, 0);
248
- return resources;
130
+ return loadSkillsFromDirectory(real, diagnostics, {
131
+ boundary: root,
132
+ codePrefix: "plugin",
133
+ });
249
134
  }
250
135
  function loadMcp(root, pluginName, diagnostics) {
251
136
  const location = join(root, "mcp.json");
@@ -482,10 +367,3 @@ function isInside(root, target) {
482
367
  function isRecord(value) {
483
368
  return !!value && typeof value === "object" && !Array.isArray(value);
484
369
  }
485
- function unquote(value) {
486
- const trimmed = value.trim();
487
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
488
- (trimmed.startsWith("'") && trimmed.endsWith("'")))
489
- return trimmed.slice(1, -1);
490
- return trimmed;
491
- }
@@ -1,5 +1,5 @@
1
+ import { formatSkillCatalog, SKILLS_USAGE } from "../skills/catalog.js";
1
2
  import { loadPlugin, } from "./load.js";
2
- const SKILL_USAGE = "The following skills provide specialized instructions. When a task matches a skill description, call load_skill with that name before proceeding.";
3
3
  /** Read an Agent Plugin package and return one capability for `Agent.use`. */
4
4
  export function plugin(directory) {
5
5
  const loaded = loadPlugin(directory);
@@ -9,10 +9,13 @@ export function capabilityFromPlugin(loaded) {
9
9
  const names = Object.values(loaded.skills);
10
10
  const instructions = names.length === 0
11
11
  ? undefined
12
- : [SKILL_USAGE, formatCatalog(names.map((skill) => ({
12
+ : [
13
+ SKILLS_USAGE,
14
+ formatSkillCatalog(names.map((skill) => ({
13
15
  name: skill.name,
14
16
  description: skill.description,
15
- })))];
17
+ }))),
18
+ ];
16
19
  const skills = Object.fromEntries(names.map((skill) => [
17
20
  skill.name,
18
21
  { name: skill.name, description: skill.description },
@@ -30,16 +33,3 @@ export function capabilityFromPlugin(loaded) {
30
33
  diagnostics: loaded.diagnostics,
31
34
  };
32
35
  }
33
- function formatCatalog(skills) {
34
- const entries = skills
35
- .map((skill) => ` <skill>\n <name>${escapeXml(skill.name)}</name>\n <description>${escapeXml(skill.description)}</description>\n </skill>`)
36
- .join("\n");
37
- return `<available_skills>\n${entries}\n</available_skills>`;
38
- }
39
- function escapeXml(value) {
40
- return value
41
- .replaceAll("&", "&amp;")
42
- .replaceAll("<", "&lt;")
43
- .replaceAll(">", "&gt;")
44
- .replaceAll('"', "&quot;");
45
- }
@@ -0,0 +1,2 @@
1
+ export { sandbox, SandboxError } from "./sandbox.js";
2
+ export type { SandboxCapability, SandboxCapabilityOptions, SandboxOptions, } from "./sandbox.js";
@@ -0,0 +1 @@
1
+ export { sandbox, SandboxError } from "./sandbox.js";
@@ -0,0 +1,28 @@
1
+ import type { CapabilityDeclaration, SandboxManifest, ToolDefinition } from "@nylorun/core/define";
2
+ /** What computer the agent needs. The Runtime decides where it runs. */
3
+ export type SandboxOptions = SandboxManifest;
4
+ export interface SandboxCapabilityOptions {
5
+ /** Capability id. Defaults to `"sandbox"`. */
6
+ readonly id?: string;
7
+ }
8
+ export interface SandboxCapability extends CapabilityDeclaration {
9
+ readonly sandbox: SandboxManifest;
10
+ readonly tools: readonly ToolDefinition[];
11
+ }
12
+ export declare class SandboxError extends Error {
13
+ readonly code: string;
14
+ constructor(code: string, message: string);
15
+ }
16
+ /**
17
+ * Give the agent an isolated Linux machine with a persistent `/workspace`.
18
+ *
19
+ * Adds the built-in `bash`, `read`, `write`, `edit`, `grep` and `glob` tools. They run in the
20
+ * Nylorun Runtime, which picks the backend (a microsandbox VM where available). Agent code
21
+ * declares requirements only.
22
+ *
23
+ * ```ts
24
+ * Agent({ id: "analyst", instructions: "..." }).use(sandbox())
25
+ * Agent({ ... }).use(sandbox({ image: "node:24", network: { allow: ["api.github.com"] } }))
26
+ * ```
27
+ */
28
+ export declare function sandbox(options?: SandboxOptions, capability?: SandboxCapabilityOptions): SandboxCapability;
@@ -0,0 +1,93 @@
1
+ import { SANDBOX_DEFERRED_FIELDS, SANDBOX_FIELDS, SANDBOX_INSTRUCTIONS, SANDBOX_NETWORK_PRESETS, createSandboxTools, isSandboxHostPattern, parseSandboxDuration, parseSandboxSize, } from "@nylorun/core/define";
2
+ export class SandboxError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.name = "SandboxError";
7
+ this.code = code;
8
+ }
9
+ }
10
+ /**
11
+ * Give the agent an isolated Linux machine with a persistent `/workspace`.
12
+ *
13
+ * Adds the built-in `bash`, `read`, `write`, `edit`, `grep` and `glob` tools. They run in the
14
+ * Nylorun Runtime, which picks the backend (a microsandbox VM where available). Agent code
15
+ * declares requirements only.
16
+ *
17
+ * ```ts
18
+ * Agent({ id: "analyst", instructions: "..." }).use(sandbox())
19
+ * Agent({ ... }).use(sandbox({ image: "node:24", network: { allow: ["api.github.com"] } }))
20
+ * ```
21
+ */
22
+ export function sandbox(options = {}, capability = {}) {
23
+ const id = capability.id ?? "sandbox";
24
+ if (!isRecord(options))
25
+ throw new SandboxError("sandbox.invalid", "sandbox() expects an options object");
26
+ for (const key of Object.keys(options)) {
27
+ if (SANDBOX_DEFERRED_FIELDS.includes(key))
28
+ throw new SandboxError("sandbox.unsupported", `sandbox({ ${key} }) is not supported in this version. ` +
29
+ `Supported options: ${SANDBOX_FIELDS.join(", ")}. See docs/design/sandboxes.md for what is planned.`);
30
+ if (!SANDBOX_FIELDS.includes(key))
31
+ throw new SandboxError("sandbox.unknown-option", `Unknown sandbox option '${key}'. Supported options: ${SANDBOX_FIELDS.join(", ")}`);
32
+ }
33
+ return Object.freeze({
34
+ id,
35
+ instructions: Object.freeze([SANDBOX_INSTRUCTIONS]),
36
+ tools: createSandboxTools(),
37
+ sandbox: freezeOptions(options),
38
+ });
39
+ }
40
+ function freezeOptions(options) {
41
+ const result = {};
42
+ if (options.image !== undefined) {
43
+ if (typeof options.image !== "string" || options.image.trim().length === 0)
44
+ throw new SandboxError("sandbox.invalid-image", "sandbox({ image }) must be a non-empty OCI image reference");
45
+ result.image = options.image;
46
+ }
47
+ if (options.network !== undefined) {
48
+ if (!isRecord(options.network))
49
+ throw new SandboxError("sandbox.invalid-network", "sandbox({ network }) must be an object");
50
+ for (const key of Object.keys(options.network))
51
+ if (key !== "preset" && key !== "allow")
52
+ throw new SandboxError("sandbox.invalid-network", `Unknown network option '${key}'. Use preset and allow.`);
53
+ const { preset, allow } = options.network;
54
+ if (preset !== undefined && !SANDBOX_NETWORK_PRESETS.includes(preset))
55
+ throw new SandboxError("sandbox.invalid-network", `network.preset must be one of ${SANDBOX_NETWORK_PRESETS.join(", ")}`);
56
+ if (allow !== undefined) {
57
+ if (!Array.isArray(allow))
58
+ throw new SandboxError("sandbox.invalid-network", "network.allow must be an array of host names");
59
+ for (const host of allow)
60
+ if (typeof host !== "string" || !isSandboxHostPattern(host))
61
+ throw new SandboxError("sandbox.invalid-network", `network.allow entry '${String(host)}' must be a host name such as api.github.com or *.example.com (no scheme, port or path)`);
62
+ }
63
+ result.network = Object.freeze({
64
+ ...(preset === undefined ? {} : { preset: preset }),
65
+ ...(allow === undefined ? {} : { allow: Object.freeze([...allow]) }),
66
+ });
67
+ }
68
+ if (options.resources !== undefined) {
69
+ if (!isRecord(options.resources))
70
+ throw new SandboxError("sandbox.invalid-resources", "sandbox({ resources }) must be an object");
71
+ for (const key of Object.keys(options.resources))
72
+ if (key !== "cpus" && key !== "memory")
73
+ throw new SandboxError("sandbox.invalid-resources", `resources.${key} is not supported in this version. Use cpus and memory.`);
74
+ const { cpus, memory } = options.resources;
75
+ if (cpus !== undefined && (!Number.isInteger(cpus) || cpus < 1 || cpus > 64))
76
+ throw new SandboxError("sandbox.invalid-resources", "resources.cpus must be an integer from 1 to 64");
77
+ if (memory !== undefined && (typeof memory !== "string" || parseSandboxSize(memory) === undefined))
78
+ throw new SandboxError("sandbox.invalid-resources", "resources.memory must be a size such as 512MiB or 2GiB");
79
+ result.resources = Object.freeze({
80
+ ...(cpus === undefined ? {} : { cpus }),
81
+ ...(memory === undefined ? {} : { memory }),
82
+ });
83
+ }
84
+ if (options.idle !== undefined) {
85
+ if (typeof options.idle !== "string" || parseSandboxDuration(options.idle) === undefined)
86
+ throw new SandboxError("sandbox.invalid-idle", "sandbox({ idle }) must be a duration such as 30s, 15m or 1h");
87
+ result.idle = options.idle;
88
+ }
89
+ return Object.freeze(result);
90
+ }
91
+ function isRecord(value) {
92
+ return !!value && typeof value === "object" && !Array.isArray(value);
93
+ }
@@ -0,0 +1,5 @@
1
+ export declare const SKILLS_USAGE = "The following skills provide specialized instructions. When a task matches a skill description, call load_skill with that name before proceeding.";
2
+ export declare function formatSkillCatalog(skills: readonly {
3
+ name: string;
4
+ description: string;
5
+ }[]): string;
@@ -0,0 +1,14 @@
1
+ export const SKILLS_USAGE = "The following skills provide specialized instructions. When a task matches a skill description, call load_skill with that name before proceeding.";
2
+ export function formatSkillCatalog(skills) {
3
+ const entries = skills
4
+ .map((skill) => ` <skill>\n <name>${escapeXml(skill.name)}</name>\n <description>${escapeXml(skill.description)}</description>\n </skill>`)
5
+ .join("\n");
6
+ return `<available_skills>\n${entries}\n</available_skills>`;
7
+ }
8
+ function escapeXml(value) {
9
+ return value
10
+ .replaceAll("&", "&amp;")
11
+ .replaceAll("<", "&lt;")
12
+ .replaceAll(">", "&gt;")
13
+ .replaceAll('"', "&quot;");
14
+ }
@@ -0,0 +1,5 @@
1
+ export { skills } from "./skills.js";
2
+ export type { SkillsCapability, SkillsOptions } from "./skills.js";
3
+ export { loadSkillsFromDirectory, resolveSkillsRoot, parseSkill, SkillsError, } from "./load.js";
4
+ export type { SkillDiagnostic } from "./load.js";
5
+ export { formatSkillCatalog, SKILLS_USAGE } from "./catalog.js";
@@ -0,0 +1,3 @@
1
+ export { skills } from "./skills.js";
2
+ export { loadSkillsFromDirectory, resolveSkillsRoot, parseSkill, SkillsError, } from "./load.js";
3
+ export { formatSkillCatalog, SKILLS_USAGE } from "./catalog.js";
@@ -0,0 +1,27 @@
1
+ import type { SkillRecord } from "@nylorun/core/define";
2
+ export interface SkillDiagnostic {
3
+ readonly severity: "info" | "warning";
4
+ readonly code: string;
5
+ readonly message: string;
6
+ readonly path?: string;
7
+ }
8
+ export declare class SkillsError extends Error {
9
+ readonly code: string;
10
+ readonly diagnostics: readonly SkillDiagnostic[];
11
+ constructor(code: string, message: string, diagnostics?: readonly SkillDiagnostic[]);
12
+ }
13
+ /** Resolve a skills catalog directory; throws when missing or not a directory. */
14
+ export declare function resolveSkillsRoot(directory: string): string;
15
+ /**
16
+ * Load Agent Skills (agentskills.io) from a catalog directory.
17
+ * Each immediate subdirectory with a valid SKILL.md becomes one skill.
18
+ */
19
+ export declare function loadSkillsFromDirectory(directory: string, diagnostics: SkillDiagnostic[], options?: {
20
+ readonly boundary?: string;
21
+ readonly codePrefix?: string;
22
+ }): Readonly<Record<string, SkillRecord>>;
23
+ export declare function parseSkill(text: string, directoryName: string): {
24
+ name: string;
25
+ description: string;
26
+ instructions: string;
27
+ } | undefined;
@@ -0,0 +1,171 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync, } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import matter from "gray-matter";
4
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5
+ export class SkillsError extends Error {
6
+ code;
7
+ diagnostics;
8
+ constructor(code, message, diagnostics = []) {
9
+ super(message);
10
+ this.name = "SkillsError";
11
+ this.code = code;
12
+ this.diagnostics = diagnostics;
13
+ }
14
+ }
15
+ /** Resolve a skills catalog directory; throws when missing or not a directory. */
16
+ export function resolveSkillsRoot(directory) {
17
+ const resolved = resolve(directory);
18
+ if (!existsSync(resolved))
19
+ throw new SkillsError("skills.missing", `Skills directory does not exist: ${directory}`);
20
+ const real = realpathSync(resolved);
21
+ if (!statSync(real).isDirectory())
22
+ throw new SkillsError("skills.missing", `Skills path is not a directory: ${directory}`);
23
+ return real;
24
+ }
25
+ /**
26
+ * Load Agent Skills (agentskills.io) from a catalog directory.
27
+ * Each immediate subdirectory with a valid SKILL.md becomes one skill.
28
+ */
29
+ export function loadSkillsFromDirectory(directory, diagnostics, options = {}) {
30
+ const root = resolveSkillsRoot(directory);
31
+ const boundary = options.boundary ?? root;
32
+ const prefix = options.codePrefix ?? "skills";
33
+ const skills = {};
34
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
35
+ if (!entry.isDirectory() || entry.name.startsWith("."))
36
+ continue;
37
+ if (entry.name === "node_modules" || entry.name === ".git")
38
+ continue;
39
+ const skillDir = join(root, entry.name);
40
+ const directoryReal = realInside(boundary, skillDir);
41
+ if (!directoryReal) {
42
+ diagnostics.push({
43
+ severity: "warning",
44
+ code: `${prefix}.skill-skipped`,
45
+ message: `Skipped skill '${entry.name}' because its directory escapes the package`,
46
+ path: skillDir,
47
+ });
48
+ continue;
49
+ }
50
+ const skillFile = join(directoryReal, "SKILL.md");
51
+ if (!existsSync(skillFile))
52
+ continue;
53
+ const skillReal = realInside(boundary, skillFile);
54
+ if (!skillReal || !statSync(skillReal).isFile()) {
55
+ diagnostics.push({
56
+ severity: "warning",
57
+ code: `${prefix}.skill-skipped`,
58
+ message: `Skipped skill '${entry.name}' because SKILL.md escapes the package`,
59
+ path: skillFile,
60
+ });
61
+ continue;
62
+ }
63
+ const parsed = parseSkill(readFileSync(skillReal, "utf8"), entry.name);
64
+ if (!parsed) {
65
+ diagnostics.push({
66
+ severity: "warning",
67
+ code: `${prefix}.skill-skipped`,
68
+ message: `Skipped skill '${entry.name}' because SKILL.md is not a valid Agent Skill`,
69
+ path: skillFile,
70
+ });
71
+ continue;
72
+ }
73
+ if (skills[parsed.name]) {
74
+ diagnostics.push({
75
+ severity: "warning",
76
+ code: `${prefix}.skill-skipped`,
77
+ message: `Skipped duplicate skill '${parsed.name}'`,
78
+ path: skillFile,
79
+ });
80
+ continue;
81
+ }
82
+ const resources = readResources(boundary, directoryReal, diagnostics, prefix);
83
+ skills[parsed.name] = {
84
+ name: parsed.name,
85
+ description: parsed.description,
86
+ instructions: parsed.instructions,
87
+ ...(Object.keys(resources).length === 0 ? {} : { resources }),
88
+ };
89
+ }
90
+ return skills;
91
+ }
92
+ export function parseSkill(text, directoryName) {
93
+ let parsed;
94
+ try {
95
+ parsed = matter(text);
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ const name = asTrimmedString(parsed.data.name);
101
+ const description = asTrimmedString(parsed.data.description);
102
+ if (!name || !description || description.length > 1024)
103
+ return undefined;
104
+ if (name.length > 64 || !SKILL_NAME.test(name) || name !== directoryName)
105
+ return undefined;
106
+ return {
107
+ name,
108
+ description,
109
+ instructions: parsed.content.replace(/^\r?\n/, ""),
110
+ };
111
+ }
112
+ function asTrimmedString(value) {
113
+ return typeof value === "string" ? value.trim() : "";
114
+ }
115
+ function readResources(boundary, directory, diagnostics, prefix) {
116
+ const resources = {};
117
+ const walk = (current, depth) => {
118
+ if (depth > 6)
119
+ return;
120
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
121
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
122
+ continue;
123
+ const full = join(current, entry.name);
124
+ if (entry.isDirectory()) {
125
+ const real = realInside(boundary, full);
126
+ if (!real)
127
+ continue;
128
+ walk(real, depth + 1);
129
+ continue;
130
+ }
131
+ if (!entry.isFile() && !entry.isSymbolicLink())
132
+ continue;
133
+ if (depth === 0 && entry.name === "SKILL.md")
134
+ continue;
135
+ const real = realInside(boundary, full);
136
+ if (!real || !statSync(real).isFile()) {
137
+ diagnostics.push({
138
+ severity: "warning",
139
+ code: `${prefix}.resource-denied`,
140
+ message: `Denied resource outside the package: ${entry.name}`,
141
+ path: full,
142
+ });
143
+ continue;
144
+ }
145
+ const bytes = readFileSync(real);
146
+ if (bytes.includes(0))
147
+ continue;
148
+ const rel = relative(directory, real).split(sep).join("/");
149
+ if (!rel || rel.startsWith(".."))
150
+ continue;
151
+ resources[rel] = bytes.toString("utf8");
152
+ }
153
+ };
154
+ walk(directory, 0);
155
+ return resources;
156
+ }
157
+ function realInside(root, candidate) {
158
+ if (!existsSync(candidate))
159
+ return undefined;
160
+ try {
161
+ const real = realpathSync(candidate);
162
+ return isInside(root, real) ? real : undefined;
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ }
168
+ function isInside(root, target) {
169
+ const rel = relative(root, target);
170
+ return (rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)));
171
+ }
@@ -0,0 +1,28 @@
1
+ import type { CapabilityDeclaration } from "@nylorun/core/define";
2
+ import { type SkillDiagnostic } from "./load.js";
3
+ export interface SkillsOptions {
4
+ /** Capability id. Defaults to the catalog directory basename. */
5
+ readonly id?: string;
6
+ }
7
+ export interface SkillsCapability extends CapabilityDeclaration {
8
+ readonly diagnostics: readonly SkillDiagnostic[];
9
+ /** Absolute catalog directory that was loaded. */
10
+ readonly root: string;
11
+ }
12
+ /**
13
+ * Load an agentskills.io catalog folder and return one capability for `Agent.use`.
14
+ *
15
+ * Expected layout:
16
+ * ```
17
+ * assistant-skills/
18
+ * lookup-order/SKILL.md
19
+ * refund/
20
+ * SKILL.md
21
+ * references/policy.md
22
+ * ```
23
+ *
24
+ * Sets both `skills` (manifest names/descriptions) and `skillRecords` (bodies +
25
+ * resources) so callers do not duplicate content. The harness injects
26
+ * `load_skill` / `read_skill_resource` from the records.
27
+ */
28
+ export declare function skills(directory: string, options?: SkillsOptions): SkillsCapability;
@@ -0,0 +1,47 @@
1
+ import { basename } from "node:path";
2
+ import { formatSkillCatalog, SKILLS_USAGE } from "./catalog.js";
3
+ import { loadSkillsFromDirectory, resolveSkillsRoot, } from "./load.js";
4
+ /**
5
+ * Load an agentskills.io catalog folder and return one capability for `Agent.use`.
6
+ *
7
+ * Expected layout:
8
+ * ```
9
+ * assistant-skills/
10
+ * lookup-order/SKILL.md
11
+ * refund/
12
+ * SKILL.md
13
+ * references/policy.md
14
+ * ```
15
+ *
16
+ * Sets both `skills` (manifest names/descriptions) and `skillRecords` (bodies +
17
+ * resources) so callers do not duplicate content. The harness injects
18
+ * `load_skill` / `read_skill_resource` from the records.
19
+ */
20
+ export function skills(directory, options = {}) {
21
+ const root = resolveSkillsRoot(directory);
22
+ const diagnostics = [];
23
+ const records = loadSkillsFromDirectory(root, diagnostics);
24
+ const listed = Object.values(records);
25
+ const id = options.id ?? basename(root);
26
+ if (listed.length === 0) {
27
+ return { id, root, diagnostics };
28
+ }
29
+ const skillManifest = Object.fromEntries(listed.map((skill) => [
30
+ skill.name,
31
+ { name: skill.name, description: skill.description },
32
+ ]));
33
+ return {
34
+ id,
35
+ root,
36
+ instructions: [
37
+ SKILLS_USAGE,
38
+ formatSkillCatalog(listed.map((skill) => ({
39
+ name: skill.name,
40
+ description: skill.description,
41
+ }))),
42
+ ],
43
+ skills: skillManifest,
44
+ skillRecords: records,
45
+ diagnostics,
46
+ };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/agents",
3
- "version": "0.3.0-beta",
3
+ "version": "0.5.0-beta",
4
4
  "description": "Nylorun agent definitions, session client and connected executor",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -35,7 +35,8 @@
35
35
  "test": "vitest run --passWithNoTests"
36
36
  },
37
37
  "dependencies": {
38
- "@nylorun/core": "0.2.0-beta"
38
+ "@nylorun/core": "0.4.0-beta",
39
+ "gray-matter": "^4.0.3"
39
40
  },
40
41
  "peerDependencies": {
41
42
  "zod": "^4.6.5"