Skip to content
Merged
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
6 changes: 6 additions & 0 deletions migrations/0012_skill_feed_metadata.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ALTER TABLE skills ADD COLUMN onboarding_text TEXT;
ALTER TABLE skill_submissions ADD COLUMN onboarding_text TEXT;
ALTER TABLE publish_events ADD COLUMN type TEXT NOT NULL DEFAULT 'skill.published';

CREATE INDEX IF NOT EXISTS idx_publish_events_type_created ON publish_events (type, created_at);
CREATE INDEX IF NOT EXISTS idx_publish_events_created ON publish_events (created_at);
18 changes: 15 additions & 3 deletions public/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,17 @@ If the client supports OAuth discovery, start at the protected resource metadata

After connecting, verify the server by listing tools and resources. The expected core tools are `skills.feed`, `skills.search`, and `skills.submit`. `skills.feed`, `skills.search`, and resource reads require `skills:read`; `skills.submit` requires `skills:submit`.

If the user wants to submit under a publishing profile, read:

```text
everyskill://account/publisher-profiles
```

Use `organization_id` from that resource for organization publishers. Omit `organization_id` to submit under the user's personal profile.

## Feed

Call the `skills.feed` tool during a heartbeat or when the user asks what's new. The feed is personalized to the authorized user's saved category preferences and can also include editorial featured pushes from Every.
Call the `skills.feed` tool during a heartbeat or when the user asks what's new. The feed is personalized to the authorized user's saved category preferences and can also include editorial featured pushes from Every. Feed items may be `skill.published`, `skill.updated`, or `skill.featured`.

Example:

Expand All @@ -50,7 +58,7 @@ Example:
}
```

The tool returns `next_since`; store that value locally and pass it as `since` on the next heartbeat. Feed items include the human skill page URL and an `everyskill://` manifest resource URI. If the user wants to install a feed item, read the manifest resource and then read the listed file resources.
The tool returns `next_since`; store that value locally and pass it as `since` on the next heartbeat. Feed items include a short `summary`, optional `changelog`, the human skill page URL, and an `everyskill://` manifest resource URI. If the user wants to install a feed item, read the manifest resource and then read the listed file resources.

## Search

Expand Down Expand Up @@ -87,6 +95,7 @@ Manifest resources are expected to include fields like:
- `description`
- `access`
- `entitlement`
- `onboarding_note`, when the skill has first-time setup guidance
- `package_url`, when ZIP installation is available
- `files[]`, with each file's relative `path`, `resource_uri`, content type, and lock state

Expand All @@ -102,6 +111,8 @@ Prefer file resources over scraping the web UI. Use `package_url` only when the

If you know the host agent's native skill directory, install there. If the host has no clear skill convention, create a project-local skill folder, preserve the package tree, and tell the user where the files were written.

If the manifest includes `onboarding_note`, use it after installation to ask the user only the setup questions needed to make the skill usable.

Paid skills may return locked resources for accounts without access. Do not try to infer or reconstruct locked package content.

## Resource files
Expand Down Expand Up @@ -133,6 +144,7 @@ Call `skills.submit` with:
{
"skill_name": "lowercase-skill-slug",
"submission_reason": "Why this skill should be reviewed and included.",
"onboarding_note": "Optional first-time setup guidance for agents.",
"files": [
{
"path": "SKILL.md",
Expand All @@ -145,4 +157,4 @@ Call `skills.submit` with:

Use text encoding for normal markdown/code files. Use base64 encoding for binary assets. Preserve the file tree exactly.

If the user wants to submit under a publishing profile, pass `organization_id`; the user must already belong to that organization. The submission will enter Every's review queue and will not publish automatically.
If the user wants to submit under a publishing profile, first read `everyskill://account/publisher-profiles`, then pass an organization profile's `organization_id`; the user must already belong to that organization. Omit `organization_id` for the personal profile. The submission will enter Every's review queue and will not publish automatically.
5 changes: 3 additions & 2 deletions scripts/export-runtime-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ function exportD1Seed() {
const entry = seedCatalog[skill.slug];

lines.push(
`INSERT OR REPLACE INTO skills (id, slug, name, description, tagline, version, status, access_level, organization_id, submitted_by_user_id, categories_json, tags_json, featured, featured_order, published_at, updated_at, r2_prefix, package_key, file_count) VALUES (${[
`INSERT OR REPLACE INTO skills (id, slug, name, description, tagline, version, status, access_level, organization_id, submitted_by_user_id, categories_json, tags_json, featured, featured_order, onboarding_text, published_at, updated_at, r2_prefix, package_key, file_count) VALUES (${[
sql(id),
sql(skill.slug),
sql(skill.name),
Expand All @@ -107,6 +107,7 @@ function exportD1Seed() {
sql(JSON.stringify(skill.tags)),
skill.featured ? 1 : 0,
entry?.featuredOrder ?? 999,
sql(skill.onboardingNote || null),
sql(skill.publishedAt),
sql(skill.updatedAt),
sql(prefix),
Expand All @@ -125,7 +126,7 @@ function exportD1Seed() {
`INSERT OR REPLACE INTO skill_versions (id, skill_id, version, r2_prefix, package_key, file_count, published_at, created_by_user_id) VALUES (${sql(versionId)}, ${sql(id)}, ${sql(version)}, ${sql(prefix)}, ${sql(packageKey)}, ${skill.fileCount}, ${sql(skill.updatedAt)}, NULL);`,
);
lines.push(
`INSERT OR REPLACE INTO publish_events (id, skill_id, version_id, source) VALUES (${sql(`seed_${versionId}`)}, ${sql(id)}, ${sql(versionId)}, 'seed');`,
`INSERT OR REPLACE INTO publish_events (id, type, skill_id, version_id, source) VALUES (${sql(`seed_${versionId}`)}, 'skill.published', ${sql(id)}, ${sql(versionId)}, 'seed');`,
);
if (skill.featured) {
lines.push(
Expand Down
2 changes: 2 additions & 0 deletions scripts/file-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function readSkillSummary(slug: string, frontmatter?: Record<string, unknown>):
const { data } = frontmatter ? { data: frontmatter } : matter(content);
const metadata = seedCatalog[slug];
const description = asString(data.description) || '';
const onboardingNote = asString(data.onboarding_note) || asString(data.onboarding) || asString(data.setup) || '';
const tags = Array.isArray(data.tags) ? data.tags.map(String) : [];
const fileCount = countFiles(path.join(skillsDir, slug));

Expand All @@ -76,6 +77,7 @@ function readSkillSummary(slug: string, frontmatter?: Record<string, unknown>):
featured: Boolean(metadata?.featured),
featuredOrder: metadata?.featuredOrder ?? 999,
accessLevel: metadata?.accessLevel || 'public',
onboardingNote,
publishedAt: metadata?.publishedAt || '2026-03-17',
updatedAt: metadata?.updatedAt || '2026-03-17',
rawPath: `/skills/${slug}/SKILL.md`,
Expand Down
44 changes: 44 additions & 0 deletions src/lib/mcp/account-resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { listUserOrganizations } from '../account';
import { requireRuntimeDatabase } from '../runtime/bindings';
import type { SkillResourceContext } from './skill-resources';

export const PUBLISHER_PROFILES_RESOURCE_URI = 'everyskill://account/publisher-profiles';

export async function readPublisherProfilesResource(context: SkillResourceContext) {
if (!context.scopes?.includes('skills:submit')) {
throw new Error('Forbidden: missing skills:submit scope.');
}

if (!context.session?.user?.id) {
throw new Error('You must authorize with an Every Skill account before reading publisher profiles.');
}

const user = context.session.user;
const personalProfile = {
type: 'personal',
label: user.name || user.email || 'Personal profile',
organization_id: null,
};
const organizations = await listUserOrganizations(requireRuntimeDatabase(context.env), user.id);
const profiles = [
personalProfile,
...organizations.map((organization) => ({
type: 'organization',
label: organization.name,
organization_id: organization.id,
slug: organization.slug,
role: organization.role,
})),
];

return {
uri: PUBLISHER_PROFILES_RESOURCE_URI,
mimeType: 'application/json',
text: JSON.stringify({
schema_version: '1.0',
default_profile: personalProfile,
profiles,
submit_hint: 'Omit organization_id to submit personally, or pass an organization_id from an organization profile.',
}, null, 2),
};
}
19 changes: 17 additions & 2 deletions src/lib/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from './skill-resources';
import { submitSkillResource } from './skill-submissions';
import { getSkillFeed } from '../runtime/feed-store';
import { PUBLISHER_PROFILES_RESOURCE_URI, readPublisherProfilesResource } from './account-resources';

export function createSkillMcpApp(context: SkillResourceContext) {
const app = new Hono();
Expand Down Expand Up @@ -133,13 +134,14 @@ function createSkillMcpServer(context: SkillResourceContext) {
'skills.submit',
{
title: 'Submit Every Skill',
description: 'Submit a complete skill package into the Every Skill review queue. Requires OAuth authorization.',
description: `Submit a complete skill package into the Every Skill review queue. Requires OAuth authorization. To choose a publisher without the UI, read ${PUBLISHER_PROFILES_RESOURCE_URI} and pass an organization_id from that resource, or omit organization_id to submit personally.`,
inputSchema: {
skill_name: z.string().describe('Lowercase skill slug, such as "review-pr" or "customer-support-triage".'),
submission_reason: z.string().describe('Why this skill should be reviewed and included in Every Skill.'),
organization_id: z.string().optional().describe('Optional publishing profile / organization id. The user must be a member.'),
organization_id: z.string().nullable().optional().describe(`Optional organization publisher id from ${PUBLISHER_PROFILES_RESOURCE_URI}. Omit or pass null to submit under the user personal profile.`),
source_url: z.string().optional().describe('Optional source URL, such as the originating repository or document.'),
source_detail: z.string().optional().describe('Optional short source note for reviewers.'),
onboarding_note: z.string().optional().describe('Optional first-time setup note that agents should show or use after installing this skill.'),
files: z.array(z.object({
path: z.string().describe('Relative package path. Must include root SKILL.md. Supported folders include scripts/, references/, agents/, and assets/.'),
content: z.string().describe('File content as text by default, or base64 when encoding is "base64".'),
Expand Down Expand Up @@ -189,6 +191,19 @@ function createSkillMcpServer(context: SkillResourceContext) {
},
);

server.registerResource(
'publisher-profiles',
PUBLISHER_PROFILES_RESOURCE_URI,
{
title: 'Every Skill publisher profiles',
description: 'Personal and organization publisher profiles available to the authorized account for skills.submit.',
mimeType: 'application/json',
},
async () => ({
contents: [await readPublisherProfilesResource(context)],
}),
);

server.registerResource(
'skill-manifest',
new ResourceTemplate('everyskill://skills/{slug}', {
Expand Down
1 change: 1 addition & 0 deletions src/lib/mcp/skill-resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ async function skillManifest(context: SkillResourceContext, skill: SkillDetail)
submitted_by: skill.submittedBy,
access_level: skill.accessLevel,
access: hasAccess ? 'available' : 'locked',
onboarding_note: skill.onboardingNote || null,
published_at: skill.publishedAt,
updated_at: skill.updatedAt,
detail_url: `${webBaseUrl}/skills/${skill.slug}`,
Expand Down
4 changes: 3 additions & 1 deletion src/lib/mcp/skill-submissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import type { SkillResourceContext } from './skill-resources';
export interface SkillSubmitArgs {
skill_name: string;
submission_reason: string;
organization_id?: string;
organization_id?: string | null;
source_url?: string;
source_detail?: string;
onboarding_note?: string;
files: Array<{
path: string;
content: string;
Expand Down Expand Up @@ -56,6 +57,7 @@ export async function submitSkillResource(context: SkillResourceContext, args: S
source: 'mcp',
sourceUrl: args.source_url?.trim() || null,
sourceDetail: args.source_detail?.trim() || 'Submitted through the Every Skill MCP server.',
onboardingText: args.onboarding_note?.trim() || null,
});

return {
Expand Down
6 changes: 4 additions & 2 deletions src/lib/runtime/catalog-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface RuntimeSkillRow {
featured: number;
featured_order: number;
access_level: AccessLevel;
onboarding_text: string | null;
published_at: string;
updated_at: string;
r2_prefix: string;
Expand All @@ -52,7 +53,7 @@ export async function getAllRuntimeSkills(env: SkillRuntimeEnv): Promise<SkillSu
s.organization_id, o.name AS organization_name, o.slug AS organization_slug, o.logo AS organization_logo,
o.displayIcon AS organization_display_icon, o.description AS organization_description, o.websiteUrl AS organization_website_url,
s.submitted_by_user_id, u.name AS submitted_by_name, u.email AS submitted_by_email,
s.featured, s.featured_order, s.access_level, s.published_at, s.updated_at, s.r2_prefix, s.package_key, s.file_count
s.featured, s.featured_order, s.access_level, s.onboarding_text, s.published_at, s.updated_at, s.r2_prefix, s.package_key, s.file_count
FROM skills s
LEFT JOIN organization o ON o.id = s.organization_id
LEFT JOIN "user" u ON u.id = s.submitted_by_user_id
Expand Down Expand Up @@ -85,7 +86,7 @@ export async function getRuntimeSkill(env: SkillRuntimeEnv, slug: string): Promi
s.organization_id, o.name AS organization_name, o.slug AS organization_slug, o.logo AS organization_logo,
o.displayIcon AS organization_display_icon, o.description AS organization_description, o.websiteUrl AS organization_website_url,
s.submitted_by_user_id, u.name AS submitted_by_name, u.email AS submitted_by_email,
s.featured, s.featured_order, s.access_level, s.published_at, s.updated_at, s.r2_prefix, s.package_key, s.file_count
s.featured, s.featured_order, s.access_level, s.onboarding_text, s.published_at, s.updated_at, s.r2_prefix, s.package_key, s.file_count
FROM skills s
LEFT JOIN organization o ON o.id = s.organization_id
LEFT JOIN "user" u ON u.id = s.submitted_by_user_id
Expand Down Expand Up @@ -175,6 +176,7 @@ function rowToSummary(row: RuntimeSkillRow): SkillSummary {
featured: Boolean(row.featured),
featuredOrder: row.featured_order ?? 999,
accessLevel: row.access_level || 'public',
onboardingNote: row.onboarding_text || '',
publishedAt: row.published_at,
updatedAt: row.updated_at,
rawPath: `/skills/${row.slug}/SKILL.md`,
Expand Down
39 changes: 32 additions & 7 deletions src/lib/runtime/feed-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface SkillFeedItem {
source_event_ids: string[];
type: string;
visible_at: string;
summary: string;
changelog: string | null;
reasons: string[];
matched_categories: string[];
priority: number;
Expand All @@ -43,6 +45,7 @@ export interface SkillFeedItem {
tags: string[];
access_level: AccessLevel;
access: 'available' | 'locked';
onboarding_note: string | null;
detail_url: string;
skill_page_url: string;
resource_uri: string;
Expand All @@ -62,6 +65,8 @@ interface FeedSkillRow {
tagline: string;
version: string | null;
access_level: AccessLevel;
changelog: string | null;
onboarding_text: string | null;
categories_json: string | null;
tags_json: string | null;
}
Expand Down Expand Up @@ -120,23 +125,23 @@ async function readCategoryPublishEvents(
const result = await db
.prepare(
`
SELECT pe.id AS event_id, 'skill.published' AS event_type, datetime(pe.created_at) AS visible_at,
SELECT pe.id AS event_id, COALESCE(pe.type, 'skill.published') AS event_type, datetime(pe.created_at) AS visible_at,
'subscribed_category' AS reason, 500 AS priority,
s.slug, s.name, s.description, s.tagline, sv.version AS version, s.access_level,
s.categories_json, s.tags_json
sv.changelog, s.onboarding_text, s.categories_json, s.tags_json
FROM publish_events pe
INNER JOIN skills s ON s.id = pe.skill_id
LEFT JOIN skill_versions sv ON sv.id = pe.version_id
WHERE s.status = 'published'
AND datetime(pe.created_at) > datetime(?)
AND datetime(pe.created_at) <= datetime(?)
AND pe.created_at > datetime(?)
AND pe.created_at <= datetime(?)
AND EXISTS (
SELECT 1
FROM skill_categories sc
WHERE sc.skill_id = s.id
AND sc.category_slug IN (${placeholders})
)
ORDER BY datetime(pe.created_at) ASC, s.name COLLATE NOCASE ASC
ORDER BY pe.created_at ASC, s.name COLLATE NOCASE ASC
`,
)
.bind(since, nextSince, ...categories)
Expand All @@ -158,7 +163,7 @@ async function readFeedEvents(
SELECT fe.id AS event_id, fe.type AS event_type, datetime(fe.visible_at) AS visible_at,
fe.source AS reason, fe.priority,
s.slug, s.name, s.description, s.tagline, COALESCE(sv.version, s.version) AS version, s.access_level,
s.categories_json, s.tags_json
sv.changelog, s.onboarding_text, s.categories_json, s.tags_json
FROM feed_events fe
INNER JOIN skills s ON s.id = fe.skill_id
LEFT JOIN skill_versions sv ON sv.id = fe.version_id
Expand Down Expand Up @@ -193,9 +198,10 @@ function mergeFeedRows(rows: FeedSkillRow[]): MergedFeedRow[] {
existing.source_event_ids.push(row.event_id);
existing.reasons = Array.from(new Set([...existing.reasons, reasonFor(row)]));
existing.priority = Math.min(existing.priority, row.priority);
if (existing.event_type !== 'skill.published' && row.event_type === 'skill.published') {
if (!isPublishEventType(existing.event_type) && isPublishEventType(row.event_type)) {
existing.event_type = row.event_type;
existing.event_id = row.event_id;
existing.changelog = row.changelog;
}
if (timestampMs(row.visible_at) > timestampMs(existing.visible_at)) existing.visible_at = row.visible_at;
}
Expand Down Expand Up @@ -229,6 +235,8 @@ async function rowToFeedItem(
source_event_ids: row.source_event_ids,
type: row.event_type,
visible_at: normalizeOutputTimestamp(row.visible_at),
summary: summaryFor(row),
changelog: row.changelog || null,
reasons: reasonList(row),
matched_categories: matchedCategories,
priority: row.priority,
Expand All @@ -242,6 +250,7 @@ async function rowToFeedItem(
tags: parseList(row.tags_json),
access_level: row.access_level || 'public',
access: hasAccess ? 'available' : 'locked',
onboarding_note: row.onboarding_text || null,
detail_url: skillPageUrl,
skill_page_url: skillPageUrl,
resource_uri: resourceUri,
Expand All @@ -256,6 +265,22 @@ function reasonFor(row: FeedSkillRow) {
return row.reason || 'editorial';
}

function summaryFor(row: MergedFeedRow) {
if (row.event_type === 'skill.updated') {
return row.changelog || `${row.name} was updated.`;
}

if (row.event_type === 'skill.featured') {
return row.tagline || row.description || `${row.name} is featured by Every.`;
}

return row.tagline || row.description || `${row.name} is now available.`;
}

function isPublishEventType(eventType: string) {
return eventType === 'skill.published' || eventType === 'skill.updated';
}

function reasonList(row: MergedFeedRow) {
return row.reasons;
}
Expand Down
Loading
Loading