Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/local/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@executor/plugin-mcp": "workspace:*",
"@executor/plugin-onepassword": "workspace:*",
"@executor/plugin-openapi": "workspace:*",
"@executor/plugin-skills": "workspace:*",
"@executor/react": "workspace:*",
"@executor/runtime-quickjs": "workspace:*",
"@executor/sdk": "workspace:*",
Expand Down
5 changes: 5 additions & 0 deletions apps/local/src/server/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { graphqlPlugin } from "@executor/plugin-graphql";
import { keychainPlugin } from "@executor/plugin-keychain";
import { fileSecretsPlugin } from "@executor/plugin-file-secrets";
import { onepasswordPlugin } from "@executor/plugin-onepassword";
import { skillsPlugin } from "@executor/plugin-skills";

// In dev mode the drizzle folder sits next to the source tree. In a compiled
// binary the files are inlined via the build-time gen module below, and we
Expand Down Expand Up @@ -101,6 +102,10 @@ const createLocalPlugins = (configFile: ConfigFileSink) =>
keychainPlugin(),
fileSecretsPlugin(),
onepasswordPlugin(),
// Global / cross-cutting skills slot. Per-source skills (like the
// openapi playbook) are declared by their owning plugin under its
// own sourceId — see notes/skills.md.
skillsPlugin(),
] as const;

type LocalPlugins = ReturnType<typeof createLocalPlugins>;
Expand Down
19 changes: 19 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

144 changes: 144 additions & 0 deletions notes/skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Skills

Skills are documentation for agents — a markdown body plus a name and a
description, loaded on demand instead of living in every system prompt.
The goal is progressive disclosure: agents search a catalog, pull the
skill they need, follow its instructions to chain tools.

## Where we are today

`@executor/plugin-skills` exposes skills as static tools whose handler
returns the body. Discovery goes through the normal
`tools.list({ query })`; loading goes through `tools.invoke(id)`. No new
primitive. That shape is fine as a v1 because it costs nothing and is
forward-compatible with every client that speaks tools.

The naming-as-attachment convention (skill id `<plugin>.<slug>`,
description prefixed `Skill:`) made substring queries land the skill
next to related real tools. It's a stopgap — a real attachment point
arrives when skills move off the global plugin (below).

## The tension the convention was papering over

Global skills and per-source skills are different concepts. Today the
plugin lumps both into one flat source id `skills`. But `openapi.adding-
a-source` is semantically owned by the openapi source — it describes
that source's tools. It should live under the same source id as the
tools it documents, so `tools.list({ sourceId: "openapi" })` returns
its own documentation alongside its operations.

The per-source case will also dominate in practice. Anyone shipping a
Cloudflare / Linear / Stripe integration will ship skills alongside
their tools, not in a shared global bucket. MCP is standardizing on
exactly this.

## What MCP is doing

The first-class-primitive draft (SEP-2076: `skills/list`, `skills/get`)
was **closed 2026-02-24**. Author pivoted to the alternative. The live
direction is maintained by the Skills Over MCP Working Group (promoted
from Interest Group on 2026-04-16, charter
[here](https://modelcontextprotocol.io/community/skills-over-mcp/charter)).
Their docs are mirrored in `.references/experimental-ext-skills/`.

Accepted decisions so far:

- **Skills are Resources, not a new primitive.** Discovery via the
existing `resources/list`. Addressable URIs, not opaque names.
- **URI scheme is `skill://<skill-path>/SKILL.md`.** Sub-resources
(reference docs, examples) are siblings under the same path. Four
independent implementations converged on `skill://` before the WG
formalized it — NimbleBrain, skilljack-mcp, skills-over-mcp,
FastMCP 3.0.
- **Name and path are decoupled.** The path locates; the
`SKILL.md` frontmatter `name` identifies. A skill at
`skill://acme/billing/refunds/SKILL.md` can be named `refund-
handling` in its frontmatter.
- **Instructor format only.** Markdown content, not executable code.
Skills that need to execute local code use existing distribution
mechanisms (npx, repos) and are explicitly out of scope for
MCP-served skills.
- **Skill semantics live in frontmatter.** `version`, `allowed-tools`,
`invocation` — all in SKILL.md YAML, not in MCP `_meta`. `_meta` is
reserved for transport-specific concerns with no natural home
elsewhere.
- **Clients get a helper for loading.** Rather than every server
shipping a `load_skill` tool, clients get a built-in `read_resource`
affordance or SDK-level `list_skill_uris()`.

This informs our SDK design because our static-tool system is
effectively in-process MCP. Whatever shape MCP lands on, ours should
match 1:1 so the MCP-source adapter passes skills through without
translation.

## Short-term move

Keep skills-as-tools internally (no churn), but stop flattening
per-source skills into the global `skills` source:

1. Each plugin that ships skills owns them. `openapiSkills` moves from
"exported from `@executor/plugin-openapi` to be re-wired in
`apps/local`" to "registered directly by the openapi plugin in its
own `staticSources`."
2. `@executor/plugin-skills` exports a small `toStaticSkill(skill):
StaticToolDecl` helper — three lines of shared code so plugins don't
reimplement the `Skill:` prefix + empty schema + `Effect.succeed(body)`
boilerplate.
3. `skillsPlugin({ skills: [...] })` stays wired in `apps/local` as the
home for **cross-cutting / user-authored** skills that don't belong
to any specific source. Today it's empty.

After the move, `tools.list({ sourceId: "openapi" })` returns
`openapi.previewSpec`, `openapi.addSource`, `openapi.adding-a-source` —
the skill is literally next to the tools, without relying on substring
search ranking. The naming-as-attachment convention becomes a natural
consequence of the sourceId, not a convention.

## Longer-term refactor (deferred until SEP stabilizes)

Grow `StaticSource` to carry a sibling `skills` field next to `tools`:

```ts
interface StaticSource {
id: string;
name: string;
kind: "control" | "data";
tools: StaticToolDecl[];
skills?: StaticSkillDecl[]; // future
}
```

`StaticSkillDecl` shape tracks the WG's output — at minimum URI
(`skill://<source-id>/<path>/SKILL.md`), frontmatter (name,
description, version, allowed-tools), body. The MCP-source adapter
converts `resources/list` filtered to `skill://` into
`StaticSkillDecl[]` 1:1. Our own plugins declare them directly.

At that point the `skillsPlugin` becomes "a plugin that exposes one
static source whose `skills` array is user-authored" — not a special
concept, just another source.

Not building this now because:

- The Skills Extension SEP is still in active drafting (WG formed
four days ago as of writing).
- Sub-resource URI shape is still being refined (see
`.references/experimental-ext-skills/skill-uri-scheme.md` PR notes
about multi-segment paths and path-name decoupling).
- We don't have a Resources system in core today. Adding one to track
a moving SEP is bad timing.

When the SEP lands: rename "static tool with markdown body" →
"static skill," add the URI, thread it through the MCP adapter. The
attachment point is already correct by then, so it's a shape change
inside the same place.

## Secret capture is still open

The openapi skill currently forbids the agent from accepting secret
values in chat (see `packages/plugins/openapi/src/sdk/skills.ts`). The
user provisions values out of band. That's the safe rule, but it
leaves the UX half-built — we don't have a first-class "ask the user
for a secret, route it past the model" flow. MCP elicitation gives us
a mechanism but the UI wiring hasn't been done. Worth its own note
when we get there.
1 change: 1 addition & 0 deletions packages/plugins/openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@effect/platform-node": "catalog:",
"@executor/config": "workspace:*",
"@executor/plugin-oauth2": "workspace:*",
"@executor/plugin-skills": "workspace:*",
"@executor/sdk": "workspace:*",
"effect": "catalog:",
"openapi-types": "^12.1.3",
Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/openapi/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export {
OpenApiOAuthError,
} from "./errors";

export { openapiSkills } from "./skills";

export {
ExtractedOperation,
ExtractionResult,
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/openapi/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,12 @@ layer(TestLayer)("OpenAPI Plugin", (it) => {

const remaining = yield* executor.tools.list();
const ids = remaining.map((t) => t.id).sort();
// The openapi plugin also ships skills under its own sourceId
// (see notes/skills.md). ASCII sort puts uppercase `addSource`
// before lowercase `adding-a-source`.
expect(ids).toEqual([
"openapi.addSource",
"openapi.adding-a-source",
"openapi.previewSpec",
]);
}),
Expand Down
7 changes: 7 additions & 0 deletions packages/plugins/openapi/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
} from "./invoke";
import { resolveBaseUrl } from "./openapi-utils";
import { previewSpec, SpecPreview } from "./preview";
import { toStaticSkill } from "@executor/plugin-skills";
import { openapiSkills } from "./skills";
import {
makeDefaultOpenapiStore,
openapiSchema,
Expand Down Expand Up @@ -915,6 +917,11 @@ export const openApiPlugin = definePlugin(
scope: ctx.scopes.at(-1)!.id as string,
}),
},
// Skills ship under the same sourceId as the tools they
// document so `tools.list({ sourceId: "openapi" })` returns
// the playbook next to the operations. toStaticSkill keeps
// the on-the-wire shape identical to the global skillsPlugin.
...openapiSkills.map(toStaticSkill),
],
},
],
Expand Down
81 changes: 81 additions & 0 deletions packages/plugins/openapi/src/sdk/skills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect } from "@effect/vitest";
import { Effect } from "effect";

import { createExecutor, makeTestConfig } from "@executor/sdk";

import { openApiPlugin } from "./plugin";

// The openapi plugin ships its own skills under its own sourceId —
// NOT via the global skillsPlugin. These tests pin that attachment:
// `tools.list({ sourceId: "openapi" })` returns the playbook
// alongside the operations, no naming-convention substring trick
// needed. See notes/skills.md for why.

describe("openapi-owned skills", () => {
it.effect("the playbook lives under sourceId `openapi`, next to previewSpec/addSource", () =>
Effect.gen(function* () {
const executor = yield* createExecutor(
makeTestConfig({ plugins: [openApiPlugin()] as const }),
);

const tools = yield* executor.tools.list({ sourceId: "openapi" });
const ids = tools.map((t) => t.id).sort();

// ASCII order: uppercase S in `addSource` sorts before lowercase
// i in `adding-a-source`.
expect(ids).toEqual([
"openapi.addSource",
"openapi.adding-a-source",
"openapi.previewSpec",
]);
}),
);

it.effect("skill description uses the `Skill:` prefix shared across skill helpers", () =>
Effect.gen(function* () {
const executor = yield* createExecutor(
makeTestConfig({ plugins: [openApiPlugin()] as const }),
);

const tools = yield* executor.tools.list({ sourceId: "openapi" });
const skill = tools.find((t) => t.id === "openapi.adding-a-source");
expect(skill?.description.startsWith("Skill: ")).toBe(true);
}),
);

it.effect("invoking the skill returns markdown that references the real tools", () =>
Effect.gen(function* () {
const executor = yield* createExecutor(
makeTestConfig({ plugins: [openApiPlugin()] as const }),
);

const body = (yield* executor.tools.invoke(
"openapi.adding-a-source",
{},
)) as string;

// If a tool gets renamed, the skill goes stale — catch it here.
expect(body).toContain("openapi.previewSpec");
expect(body).toContain("openapi.addSource");
}),
);

// Pins the "no secret values in chat" policy. If a future edit
// reintroduces the old `secrets.set` instruction, this fails —
// that pattern routes user-typed secrets through the LLM context.
it.effect("skill body never tells the agent to secrets.set a user-typed value", () =>
Effect.gen(function* () {
const executor = yield* createExecutor(
makeTestConfig({ plugins: [openApiPlugin()] as const }),
);

const body = (yield* executor.tools.invoke(
"openapi.adding-a-source",
{},
)) as string;

expect(body).not.toContain("store it via `secrets.set`");
expect(body).toContain("Never accept a secret value in-chat");
}),
);
});
Loading
Loading