# Phase 45: Content as Skills - Research
**Researched:** 2026-04-04
**Domain:** Nexus skill registry — local SKILL.md authoring, source seeding, group membership, generalist agent preloading
**Confidence:** HIGH
## Summary
Phase 45 bridges the content generation work of Phases 41-44 with the skill registry system already in place. Every content type (diagram, icon, theme, wallpaper, social, convert, pdf-document, brand-kit, presentation) must become an installable Markdown skill, the built-in "Creative" group must contain all of them, and the Generalist agent must ship pre-loaded with that group.
The skill registry infrastructure is complete. The registry stores skills in a libSQL database under `~/.paperclip/instances/default/skills/registry.db`. Skills are fetched from remote GitHub sources (`anthropic-marketplace` and `github-tree` types) or from native agent runtimes (Hermes). There is currently no local-filesystem source type. Phase 45 must add one, seed it on server startup, and wire the Creative group to the new skills.
The generalist agent preloading mechanism already exists: `index.ts` seeds `pendingSkillGroups: ["Creative"]` in agent metadata at creation time, and a fire-and-forget startup reconciler assigns the group (using `assignGroup`) to every agent that has that metadata flag. Phase 45 needs the Creative group to have members before the reconciler runs — which means the skill SKILL.md files and their registry entries must be in place before any agent is created.
**Primary recommendation:** Add a `"local-nexus-content"` source type to the skill registry fetcher. On server startup, register nine content SKILL.md files from `server/src/skills/content/` into the registry DB and add all of them to the `builtin/creative` group.
---
## User Constraints (from CONTEXT.md)
### Locked Decisions
All implementation choices are at Claude's discretion — discuss phase was skipped per user setting.
### Claude's Discretion
All implementation choices are at Claude's discretion. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.
### Deferred Ideas (OUT OF SCOPE)
None.
---
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| SKILL-01 | Each content type is implemented as an installable Nexus skill | Nine SKILL.md files authored; registered in skill registry DB via local-nexus-content source type; installable through existing `svc.install()` path |
| SKILL-02 | Generalist agent is pre-loaded with a "Creative" skill group | `builtin/creative` group already exists in DB seed; group members populated on startup; `pendingSkillGroups` reconciler already assigns the group to Generalist agents |
| SKILL-03 | Users can add or remove content type skills through the Skill Aggregator | Content skills appear in the existing SkillBrowser UI once registered; install/uninstall routes already work for any registered skill |
---
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| libSQL / drizzle-orm | Already installed | Skill registry persistence | All skill data lives in this SQLite DB — no new dep needed |
| node:fs/promises | Node built-in | Read SKILL.md files from disk | Used by existing skill cache logic |
| vitest | Already installed | Unit tests | Project standard in `server/` |
### Supporting
No new packages required. The full content generation stack (jobType dispatch, renderers, SSE) is already in place from Phases 40-44.
**Installation:** None required.
---
## Architecture Patterns
### Recommended Project Structure
```
server/src/
├── skills/
│ └── content/
│ ├── diagram.SKILL.md
│ ├── icon-set.SKILL.md
│ ├── theme-palette.SKILL.md
│ ├── wallpaper.SKILL.md
│ ├── social-post.SKILL.md
│ ├── convert.SKILL.md
│ ├── pdf-document.SKILL.md
│ ├── brand-kit.SKILL.md
│ └── presentation.SKILL.md
├── services/
│ └── skill-registry-fetcher.ts ← add local-nexus-content source type
│ └── skill-registry-db.ts ← seed Creative group members on init
└── index.ts ← call seedContentSkills on startup
```
### Pattern 1: Local-Filesystem Source Type
**What:** A new `"local-nexus-content"` entry in `SkillSourceConfig` union and a `fetchLocalNexusContent()` function that reads SKILL.md files from `server/src/skills/content/` and upserts them into the registry DB.
**When to use:** Server startup — called once after `getSkillRegistryDb()` initialises.
**Example:**
```typescript
// In skill-registry-fetcher.ts — extend the union type
export type SkillSourceConfig =
| { id: string; type: "anthropic-marketplace"; owner: string; repo: string; ref: string; label: string }
| { id: string; type: "github-tree"; owner: string; repo: string; ref: string; label: string }
| { id: string; type: "local-nexus-content"; dir: string; label: string };
// New handler called by fetchAllSources when type === "local-nexus-content"
async function fetchLocalNexusContent(
source: Extract,
db: SkillRegistryDb,
): Promise {
const entries = await readdir(source.dir, { withFileTypes: true });
let fetched = 0;
for (const entry of entries) {
if (!entry.name.endsWith(".SKILL.md")) continue;
const slug = entry.name.replace(/\.SKILL\.md$/, "");
const skillId = `${source.id}/${slug}`;
const skillMdContent = await readFile(path.join(source.dir, entry.name), "utf-8");
const { name, description } = parseSkillFrontmatter(skillMdContent);
// Use a content hash as the version SHA (deterministic, no network needed)
const sha = crypto.createHash("sha1").update(skillMdContent).digest("hex");
await upsertSkill(db, { skillId, sourceId: source.id, name: name ?? slug, description, sourceUrl: "" });
await cacheSkillVersion(db, { skillId, sha, skillMdContent, skillMdUrl: "" });
await upsertCommunityRatingsStub(db, skillId, source.id);
fetched++;
}
return fetched;
}
```
**BUILT_IN_SOURCES addition:**
```typescript
export const BUILT_IN_SOURCES: SkillSourceConfig[] = [
// … existing remote sources …
{
id: "nexus-content",
type: "local-nexus-content",
dir: path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "skills", "content"),
label: "Nexus Content Tools",
},
];
```
### Pattern 2: Creative Group Membership Seeding
**What:** After the local skills are registered, add them as members of the `builtin/creative` group. This should happen inside `getSkillRegistryDb()` (or a startup helper called right after) so the group has members before the `pendingSkillGroups` reconciler runs.
**When to use:** Server startup, idempotent (uses `INSERT OR IGNORE`).
```typescript
// In skill-registry-db.ts — extend seedBuiltinGroups or add seedCreativeGroupMembers
const NEXUS_CONTENT_SKILL_IDS = [
"nexus-content/diagram",
"nexus-content/icon-set",
"nexus-content/theme-palette",
"nexus-content/wallpaper",
"nexus-content/social-post",
"nexus-content/convert",
"nexus-content/pdf-document",
"nexus-content/brand-kit",
"nexus-content/presentation",
] as const;
async function seedCreativeGroupMembers(client: LibSQLClient): Promise {
const now = Date.now();
for (const skillId of NEXUS_CONTENT_SKILL_IDS) {
await client.execute({
sql: `INSERT OR IGNORE INTO skill_group_members (group_id, skill_id, added_at) VALUES (?, ?, ?)`,
args: ["builtin/creative", skillId, now],
});
}
}
```
### Pattern 3: SKILL.md Authoring Convention
Each content skill SKILL.md must follow the same frontmatter convention as existing skills:
```markdown
---
name: diagram
description: >
Generate diagrams from a natural language description. Produces Mermaid
syntax rendered to SVG/PNG. Supports architecture, flowchart, ERD,
sequence, and mind-map types. Use when a user asks to visualise a
system, process, or data structure as a diagram.
---
# Diagram Generation
Generates diagrams from a text description via the Nexus content API.
## Usage
Submit a `POST /api/content-jobs` with:
- `jobType: "diagram"`
- `input.description` — natural language description of the diagram
- `input.type` (optional) — `"flowchart"`, `"architecture"`, `"erd"`, `"sequence"`, or `"mindmap"`
The job returns a 202 with a `jobId`. Poll `GET /api/content-jobs/:jobId` or subscribe
to SSE `content_job.done` events to retrieve the resulting asset URL.
## Output
Returns an SVG file (also available as PNG via the export button in the UI).
```
### Startup Wiring
In `server/src/index.ts`, the existing startup block already calls `getSkillRegistryDb()` as fire-and-forget. Extend that block to also call `fetchAllSources()` with `BUILT_IN_SOURCES` immediately after DB init so content skills are seeded on every cold start:
```typescript
// [nexus] Initialize skill registry and seed content skills
void (async () => {
try {
const { getSkillRegistryDb } = await import("./services/skill-registry-db.js");
await getSkillRegistryDb(); // creates tables + builtin groups
const { skillRegistryService } = await import("./services/skill-registry.js");
await skillRegistryService().fetchAll(); // seeds local-nexus-content skills
logger.info("skill registry database initialized and content skills seeded");
} catch (err) {
logger.error({ err }, "skill registry init failed");
}
})();
```
### Anti-Patterns to Avoid
- **Seeding Creative group members before the skills table rows exist:** `skill_group_members.skill_id` has no FK constraint, so INSERT succeeds even if the skill row is absent — but `resolveEffectiveSkills` then returns orphaned IDs. Always seed skill rows first, group members second.
- **Calling `fetchAll()` before `getSkillRegistryDb()`:** DB tables must exist before the local fetcher tries to upsert.
- **Using mutable remote SHA for local file versioning:** Local files don't have a git SHA. Use a SHA-1 of the file content instead — deterministic and cheap.
- **Blocking HTTP on `fetchAll()` at startup:** Keep as fire-and-forget; the external github-tree fetches can be slow. Content skills (local path) are synchronous but the remote sources may timeout.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Skill install/uninstall | Custom file copy logic | `skillRegistryService().install()` / `uninstall()` | Already handles version tracking, cache dirs, agentSkills table |
| Group assignment | Direct INSERT into agentSkillGroups | `skillGroupService().assignGroup()` | Handles effective skill resolution, per-skill install, dedup |
| Skill Browser display | New UI component | Existing SkillBrowser page at `/skills` | Already renders all registered skills with install/remove actions |
| Group membership seeding | New service | `INSERT OR IGNORE INTO skill_group_members` on DB init | Simpler than adding a new route; idempotent |
---
## Common Pitfalls
### Pitfall 1: Creative group populated but skills not yet in registry
**What goes wrong:** `pendingSkillGroups` reconciler runs at startup and calls `assignGroup("builtin/creative", agentId, skillsDir)`. `resolveEffectiveSkills` returns the 9 skill IDs, but `svc.install()` fails with "Skill not found" because local skills haven't been fetched yet.
**Why it happens:** The skill-registry init and the `pendingSkillGroups` reconciler are both fire-and-forget; their order is non-deterministic.
**How to avoid:** Seed creative group members only after `fetchAll()` completes (not at DB init time). Alternatively, sequence the two startup blocks: skill init → fetchAll → then let the reconciler run.
**Warning signs:** `skipped` array non-empty in `assignGroup` result; Generalist agent has group assigned but no SKILL.md files on disk.
### Pitfall 2: Local source dir resolution broken in production
**What goes wrong:** `path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "skills", "content")` resolves to `server/src/skills/content` in dev but points to a non-existent path in a compiled/packaged build.
**Why it happens:** `import.meta.url` refers to the compiled output location.
**How to avoid:** In `tsconfig.json`, include `server/src/skills/content/*.SKILL.md` in the assets copied to dist, OR read the source dir via `process.cwd()` with a known repo-relative path. Check that the resolved dir exists before iterating; log a clear error if absent.
### Pitfall 3: SKILL.md frontmatter parse returns undefined name
**What goes wrong:** `parseSkillFrontmatter` fails silently on a multi-line YAML description block (`description: >\n line1\n line2`). The `name` field parses correctly but `description` is truncated to `>` literal.
**Why it happens:** `parseSkillFrontmatter` uses a single-line regex (`^description:\s*(.+)$`) — it does not handle YAML block scalars.
**How to avoid:** For the `name` field (used for registry display), keep it on a single line. For `description` use a single-line string or expand the frontmatter parser to handle folded scalars. Alternatively, accept the `>` literal as description — it won't break anything.
### Pitfall 4: Agent SKILL.md dir wrong for Generalist agent
**What goes wrong:** `assignGroup` copies skills to `agentSkillsDir` but the Generalist agent's adapter is `claude_local` — the skills dir is resolved by `resolveAdapterSkillConfig("claude_local").skillDir`. If the agent's workspace does not exist yet (new onboard), `cp` fails.
**Why it happens:** The workspace dir is created lazily when the agent first runs.
**How to avoid:** In `assignGroup`, `mkdir(agentSkillsDir, { recursive: true })` before attempting the copy. Inspect the existing code path — if this `mkdir` is already there, no action needed.
### Pitfall 5: Duplicate registration on restarts
**What goes wrong:** Every server restart calls `fetchAll()`, which re-registers all 9 local skills. Without idempotency guards, this creates duplicate `skill_versions` and `skill_files` rows.
**Why it happens:** The content hash SHA is deterministic, so `versionExists()` returns true and `cacheSkillVersion` is skipped. But `upsertSkill` uses `onConflictDoUpdate` — the name/description are updated but no duplicate is created.
**How to avoid:** The existing `versionExists()` check handles this. Confirm the content-hash versionId format matches: `${skillId}@${sha}`.
---
## Code Examples
### Registering skills from disk (local-nexus-content handler)
```typescript
// Source: server/src/services/skill-registry-fetcher.ts (new function)
import { readdir, readFile } from "node:fs/promises";
import crypto from "node:crypto";
async function fetchLocalNexusContent(
source: Extract,
db: SkillRegistryDb,
): Promise {
let entries: Awaited>;
try {
entries = await readdir(source.dir, { withFileTypes: true });
} catch {
// Directory doesn't exist (e.g. missing after a build step) — log and skip
return 0;
}
let fetched = 0;
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".SKILL.md")) continue;
const slug = entry.name.replace(/\.SKILL\.md$/, "");
const skillId = `${source.id}/${slug}`;
const content = await readFile(path.join(source.dir, entry.name), "utf-8");
const sha = crypto.createHash("sha1").update(content).digest("hex");
const { name, description } = parseSkillFrontmatter(content);
await upsertSkill(db, {
skillId, sourceId: source.id,
name: name ?? slug, description,
sourceUrl: "",
});
await cacheSkillVersion(db, { skillId, sha, skillMdContent: content, skillMdUrl: "" });
await upsertCommunityRatingsStub(db, skillId, source.id);
fetched++;
}
return fetched;
}
```
### Seeding Creative group membership after fetchAll
```typescript
// In skill-registry-db.ts — called after seedBuiltinGroups
async function seedCreativeGroupMembers(client: LibSQLClient): Promise {
const now = Date.now();
const SKILLS = [
"nexus-content/diagram", "nexus-content/icon-set", "nexus-content/theme-palette",
"nexus-content/wallpaper", "nexus-content/social-post", "nexus-content/convert",
"nexus-content/pdf-document", "nexus-content/brand-kit", "nexus-content/presentation",
];
for (const skillId of SKILLS) {
await client.execute({
sql: `INSERT OR IGNORE INTO skill_group_members (group_id, skill_id, added_at) VALUES (?, ?, ?)`,
args: ["builtin/creative", skillId, now],
});
}
}
```
### Ensuring Creative is assigned to a new Generalist agent
```typescript
// The existing path in index.ts (already present, no change needed):
await agentSvc.create(company.id, {
name: "Generalist",
role: "general",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
metadata: { pendingSkillGroups: ["Creative"], backfilled: true },
});
// The reconciler at server startup calls svc.assignGroup("builtin/creative", agentId, skillsDir)
// which installs all Creative group members to the agent's .claude/skills/ directory.
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Skills only from remote GitHub repos | Skills can come from local filesystem too (new local-nexus-content type) | Phase 45 | Nexus-specific content skills don't need a public GitHub repo |
| Creative group is empty (seed only creates the group row) | Creative group has 9 content skill members after server init | Phase 45 | Generalist agents automatically get all content tools on first run |
---
## Open Questions
1. **Should `cacheSkillVersion` write files to a tmpdir for local skills?**
- What we know: `cacheSkillVersion` writes `SKILL.md` to `~/.paperclip/instances/default/skills/cache/nexus-content///SKILL.md`, then `svc.install()` copies from that cache dir to the agent's skills dir.
- What's unclear: For local skills, caching to disk before installing is redundant — the source file is already on disk.
- Recommendation: Keep the cache write for consistency (install always reads from cache); the file is tiny. If performance matters, skip caching and have install copy the source file directly.
2. **Where exactly is the `server/src/skills/content/` dir resolved in compiled builds?**
- What we know: The server compiles TypeScript but does not currently bundle assets — source files are referenced at runtime via `import.meta.url`.
- What's unclear: Whether `.SKILL.md` files are included in the dist output.
- Recommendation: Add `server/src/skills/content/` to the list of directories copied during build (check the server's build script), or use `process.env.SKILL_CONTENT_DIR` as an override for production.
3. **Should the Skill Aggregator UI show a "Creative" filter tab?**
- What we know: `SkillBrowser.tsx` filters by adapter compatibility; the "Groups" tab shows group membership via `skillGroupsApi`. Content skills installed via the registry appear in the installed tab.
- What's unclear: Whether the browse experience needs a dedicated "Creative" section or if the existing group-based filtering is sufficient.
- Recommendation: No UI changes needed for SKILL-03. The existing SkillBrowser already supports install/remove for any registered skill. Adding a dedicated Creative tab is a future enhancement.
---
## Environment Availability
Step 2.6: SKIPPED (no new external dependencies — local filesystem only)
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Vitest (node environment) |
| Config file | `server/vitest.config.ts` |
| Quick run command | `cd /opt/nexus/server && npx vitest run --reporter=verbose src/__tests__/skill-registry-content-skills.test.ts` |
| Full suite command | `cd /opt/nexus/server && npx vitest run --reporter=verbose` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SKILL-01 | 9 SKILL.md files are seeded into registry DB via local-nexus-content source | unit | `npx vitest run src/__tests__/skill-registry-content-skills.test.ts -t "seeds content skills"` | ❌ Wave 0 |
| SKILL-01 | Each skill is installable to an agent skills dir | unit | `npx vitest run src/__tests__/skill-registry-content-skills.test.ts -t "installs content skill"` | ❌ Wave 0 |
| SKILL-02 | Creative group has 9 members after DB init | unit | `npx vitest run src/__tests__/skill-registry-content-skills.test.ts -t "Creative group members"` | ❌ Wave 0 |
| SKILL-03 | Content skills appear in registry list | unit | `npx vitest run src/__tests__/skill-registry-content-skills.test.ts -t "list includes content skills"` | ❌ Wave 0 |
### Sampling Rate
- **Per task commit:** `cd /opt/nexus/server && npx vitest run src/__tests__/skill-registry-content-skills.test.ts`
- **Per wave merge:** `cd /opt/nexus/server && npx vitest run`
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `server/src/__tests__/skill-registry-content-skills.test.ts` — covers SKILL-01, SKILL-02, SKILL-03
- [ ] `server/src/skills/content/*.SKILL.md` — 9 skill files (authored, not test infra)
---
## Sources
### Primary (HIGH confidence)
- Direct source code inspection — `server/src/services/skill-registry-fetcher.ts`, `skill-registry-db.ts`, `skill-registry-groups.ts`, `skill-registry.ts`
- Direct source code inspection — `server/src/index.ts` lines 256-279 (ensureGeneralistAgents), 628-658 (pendingSkillGroups reconciler)
- Direct source code inspection — `server/src/adapters/registry.ts`, `server/src/routes/skill-registry.ts`, `server/src/routes/skill-registry-groups.ts`
### Secondary (MEDIUM confidence)
- Existing test patterns from `server/src/__tests__/skill-registry-install.test.ts` and `hermes-dual-source.test.ts` — confirms vitest + real temp dirs as test pattern
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all libraries already installed; no new deps
- Architecture: HIGH — local source type follows identical pattern to existing github-tree handler
- Pitfalls: HIGH — race conditions and dir resolution issues verified against actual startup code
**Research date:** 2026-04-04
**Valid until:** 2026-05-04 (stable codebase)