H113) Practical recommendation order
Phase 1
Create import slice and ImportedSource / ImportedExport
Phase 2
Create URL + markdown ingestion endpoint
Phase 3
Project imports into ConceptGroup + ConceptCard
Phase 4
Project cards into:
- skill.md
- human_card_90
- human_card_40
- human_card_11
Phase 5
Project session stacks into:
- tutorial
- guidebook
- youtube script
- live assist
Phase 6
Build markdown browser
H1External page
→ stored as ImportedSource → projected non-destructively into current entities → exported to md/script/session/assets → browsed in app Do not use this yet: External page → only markdown files → everything else derived ad hoc from local files
- Strongest fit to your codebase
Given the modules visible now:
- concept-cards
- concept-groups
- session-stacks
- session-paths
- export roadmap already started
The right integration is:
imports = new intake boundary concept-cards/groups = domain persistence session-stacks = composition layer skill/session/script md = export layer markdown browser = read surface
H1Important implementation note
For your deployment reality, prefer:
- DB for authoritative imported/exported content
- optional filesystem mirror for browser/demo/static use
not runtime local-file writes as the only stored source.
If you want, next step can be a concrete Phase 1 code package:
- Prisma models
- createImportFromRequest.ts
- app/(pages)/imports/api/route.ts
- normalizeImportedContent.ts
- initial projectImportToConcepts.ts skeleton
H1FOLDER STRUCTURE
app/ (pages)/ imports/ page.tsx [id]/ page.tsx actions/ createImportFromUrl.ts createImportFromMarkdown.ts processImport.ts projectImportToConcepts.ts projectImportToSkillMd.ts projectImportToAssets.ts projectImportToSessionStack.ts api/ route.ts components/ ImportForm.tsx ImportResultView.tsx types/ imports.types.ts lib/ normalizeImportedContent.ts extractImportBlocks.ts deriveCardsFromImport.ts deriveSkillDocuments.ts deriveSessionStackScript.ts
lib/ export/ renderSkillMd.ts renderSessionMd.ts renderScriptMd.ts renderCardAssetMd.ts
##Prisma addition
model ImportedSource {
id String @id @default(cuid()) sourceType String // "url" | "markdown" | "html" | "manual" sourceRef String? // url or external reference title String? slug String? rawMarkdown String? rawHtml String? rawText String? normalizedMd String? renderedHtml String? status String @default("pending") // pending | processed | failed notes String? meta Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
conceptGroups ImportedConceptGroupLink[] conceptCards ImportedConceptCardLink[] exports ImportedExport[] }
model ImportedConceptGroupLink { id String @id @default(cuid()) importedSourceId String conceptGroupId String role String? // "primary" | "derived"
importedSource ImportedSource @relation(fields: [importedSourceId], references: [id], onDelete: Cascade) conceptGroup ConceptGroup @relation(fields: [conceptGroupId], references: [id], onDelete: Cascade)
@@unique([importedSourceId, conceptGroupId]) }
model ImportedConceptCardLink { id String @id @default(cuid()) importedSourceId String conceptCardId String role String? // "primary" | "derived"
importedSource ImportedSource @relation(fields: [importedSourceId], references: [id], onDelete: Cascade) conceptCard ConceptCard @relation(fields: [conceptCardId], references: [id], onDelete: Cascade)
@@unique([importedSourceId, conceptCardId]) }
model ImportedExport { id String @id @default(cuid()) importedSourceId String exportType String // "skill_md" | "session_md" | "script_md" | "html" | "card_asset" title String? slug String body String renderedHtml String? meta Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
importedSource ImportedSource @relation(fields: [importedSourceId], references: [id], onDelete: Cascade)
@@index([importedSourceId, exportType]) }
H1API ROUTE app/(pages)/imports/api/route.ts
type ImportRequest = | { mode: "url"; url: string; output?: Array<"concepts" | "skill_md" | "session_md" | "script_md" | "html" | "assets">; title?: string; groupTitle?: string; } | { mode: "markdown"; markdown: string; title?: string; output?: Array<"concepts" | "skill_md" | "session_md" | "script_md" | "html" | "assets">; groupTitle?: string; } | { mode: "html"; html: string; title?: string; output?: Array<"concepts" | "skill_md" | "session_md" | "script_md" | "html" | "assets">; groupTitle?: string; };
// app/(pages)/imports/api/route.ts import { NextRequest, NextResponse } from "next/server"; import { createImportFromRequest } from "../actions/createImportFromRequest";
export async function POST(req: NextRequest) { try { const body = await req.json(); const result = await createImportFromRequest(body);
return NextResponse.json(result, { status: 200 });
} catch (error) { return NextResponse.json( { ok: false, error: error instanceof Error ? error.message : "Import failed", }, { status: 400 } ); } }
H1SERVER ACTION
"use server";
import { z } from "zod"; import { prisma } from "@/lib/prisma"; import { fetchUrlAsNormalizedContent } from "../lib/fetchUrlAsNormalizedContent"; import { normalizeImportedContent } from "../lib/normalizeImportedContent"; import { processImport } from "./processImport";
const ImportSchema = z.object({ mode: z.enum(["url", "markdown", "html"]), url: z.string().url().optional(), markdown: z.string().optional(), html: z.string().optional(), title: z.string().optional(), groupTitle: z.string().optional(), output: z .array(z.enum(["concepts", "skill_md", "session_md", "script_md", "html", "assets"])) .optional(), });
export async function createImportFromRequest(input: unknown) { const data = ImportSchema.parse(input);
let title = data.title ?? null; let rawMarkdown: string | null = null; let rawHtml: string | null = null; let rawText: string | null = null; let sourceRef: string | null = null;
if (data.mode === "url") { const fetched = await fetchUrlAsNormalizedContent(data.url!); title = title ?? fetched.title ?? null; rawMarkdown = fetched.markdown ?? null; rawHtml = fetched.html ?? null; rawText = fetched.text ?? null; sourceRef = data.url!; }
if (data.mode === "markdown") { rawMarkdown = data.markdown ?? null; rawText = data.markdown ?? null; }
if (data.mode === "html") { rawHtml = data.html ?? null; rawText = data.html ?? null; }
const normalized = await normalizeImportedContent({ title, rawMarkdown, rawHtml, rawText, });
const imported = await prisma.importedSource.create({ data: { sourceType: data.mode, sourceRef, title: normalized.title, slug: normalized.slug, rawMarkdown, rawHtml, rawText, normalizedMd: normalized.markdown, renderedHtml: normalized.renderedHtml, meta: { requestedOutputs: data.output ?? ["concepts", "skill_md"], groupTitle: data.groupTitle ?? null, }, status: "pending", }, });
const processed = await processImport({ importedSourceId: imported.id, requestedOutputs: data.output ?? ["concepts", "skill_md"], groupTitle: data.groupTitle ?? null, });
return { ok: true, importedSourceId: imported.id, ...processed, }; }
H15) Processing pipeline
"use server";
import { prisma } from "@/lib/prisma"; import { projectImportToConcepts } from "./projectImportToConcepts"; import { projectImportToSkillMd } from "./projectImportToSkillMd"; import { projectImportToSessionStack } from "./projectImportToSessionStack"; import { projectImportToAssets } from "./projectImportToAssets";
type RequestedOutput = | "concepts" | "skill_md" | "session_md" | "script_md" | "html" | "assets";
export async function processImport({ importedSourceId, requestedOutputs, groupTitle, }: { importedSourceId: string; requestedOutputs: RequestedOutput[]; groupTitle?: string | null; }) { const created: Record<string, unknown> = {};
if (requestedOutputs.includes("concepts")) { created.concepts = await projectImportToConcepts({ importedSourceId, groupTitle, }); }
if ( requestedOutputs.includes("skill_md") || requestedOutputs.includes("session_md") || requestedOutputs.includes("script_md") || requestedOutputs.includes("html") ) { created.documents = await projectImportToSkillMd({ importedSourceId, requestedOutputs, }); }
if (requestedOutputs.includes("assets")) { created.assets = await projectImportToAssets({ importedSourceId, }); }
await prisma.importedSource.update({ where: { id: importedSourceId }, data: { status: "processed" }, });
return created; }
H16) Concept projection
"use server";
import { prisma } from "@/lib/prisma"; import { deriveCardsFromImport } from "../lib/deriveCardsFromImport";
export async function projectImportToConcepts({ importedSourceId, groupTitle, }: { importedSourceId: string; groupTitle?: string | null; }) { const imported = await prisma.importedSource.findUniqueOrThrow({ where: { id: importedSourceId }, });
const derived = deriveCardsFromImport({ title: imported.title ?? "Imported Source", markdown: imported.normalizedMd ?? "", meta: imported.meta, });
const group = await prisma.conceptGroup.create({ data: { title: groupTitle ?? derived.groupTitle, summary: derived.groupSummary ?? null, }, });
await prisma.importedConceptGroupLink.create({ data: { importedSourceId, conceptGroupId: group.id, role: "primary", }, });
const cards = [];
for (const card of derived.cards) { const createdCard = await prisma.conceptCard.create({ data: { title: card.title, hook: card.hook, promise: card.promise, boundaries: card.boundaries, durationMin: card.durationMin, difficulty: card.difficulty, domains: card.domains, intro: card.intro, notes: card.notes, }, });
await prisma.cardContent.create({
data: {
conceptCardId: createdCard.id,
steps: card.steps,
materials: card.materials,
structure: card.structure ?? {},
version: 1,
language: "en",
},
});
await prisma.conceptGroupItem.create({
data: {
conceptGroupId: group.id,
conceptCardId: createdCard.id,
},
});
await prisma.importedConceptCardLink.create({
data: {
importedSourceId,
conceptCardId: createdCard.id,
role: "primary",
},
});
cards.push(createdCard);
}
return { groupId: group.id, cardIds: cards.map((c) => c.id), cardCount: cards.length, }; }
H1SILL.md DOCS
This is where your three concerns converge.
Concern A
Generate skill md documents for system / AI assistant instructions
Concern B
Generate human-readable action cards at multiple time-depths: 90, 40, 11
Concern C
Use session stacks as stackable scripts/tutorials/guidebooks/video scripts
All three should be projections from either:
- imported source
- existing concept card group
- existing session stack
Never manually fork content.
H17.1 Multi-depth deliverables
export type DeliveryDepth = "full_90" | "medium_40" | "micro_11";
Then each concept card projection can emit:
- skill.md → assistant/system instruction version
- card-90.md → complete tutorial / session card
- card-40.md → compressed workshop / focused practical
- card-11.md → very short action card / quick guide
EXAMPLE -> skill_md human_card_90 human_card_40 human_card_11 session_md script_md html
H1projectImportToSkillMd.ts
"use server";
import { prisma } from "@/lib/prisma"; import { renderSkillMd } from "@/lib/export/renderSkillMd"; import { renderSessionMd } from "@/lib/export/renderSessionMd"; import { renderScriptMd } from "@/lib/export/renderScriptMd"; import { renderHumanCardMd } from "@/lib/export/renderHumanCardMd"; import { toExportableCard } from "@/lib/export/toExportableCard"; import { slugify } from "@/lib/slugify";
export async function projectImportToSkillMd({ importedSourceId, requestedOutputs, }: { importedSourceId: string; requestedOutputs: Array<"skill_md" | "session_md" | "script_md" | "html" | "assets" | "concepts">; }) { const links = await prisma.importedConceptCardLink.findMany({ where: { importedSourceId }, include: { conceptCard: { include: { contents: true, conceptGroupItems: { include: { conceptGroup: { include: { conceptGroupItems: { include: { conceptCard: true }, }, }, }, }, }, }, }, }, });
const exportsCreated = [];
for (const link of links) { const card = toExportableCard(link.conceptCard);
if (requestedOutputs.includes("skill_md")) {
exportsCreated.push(
await prisma.importedExport.create({
data: {
importedSourceId,
exportType: "skill_md",
title: card.title,
slug: `${slugify(card.title)}.skill`,
body: renderSkillMd(card),
},
})
);
}
if (requestedOutputs.includes("session_md")) {
exportsCreated.push(
await prisma.importedExport.create({
data: {
importedSourceId,
exportType: "session_md",
title: `${card.title} Session`,
slug: `${slugify(card.title)}.session`,
body: renderSessionMd(card),
},
})
);
}
if (requestedOutputs.includes("script_md")) {
exportsCreated.push(
await prisma.importedExport.create({
data: {
importedSourceId,
exportType: "script_md",
title: `${card.title} Script`,
slug: `${slugify(card.title)}.script`,
body: renderScriptMd(card),
},
})
);
}
exportsCreated.push(
await prisma.importedExport.create({
data: {
importedSourceId,
exportType: "human_card_90",
title: `${card.title} 90`,
slug: `${slugify(card.title)}.90`,
body: renderHumanCardMd(card, "full_90"),
},
})
);
exportsCreated.push(
await prisma.importedExport.create({
data: {
importedSourceId,
exportType: "human_card_40",
title: `${card.title} 40`,
slug: `${slugify(card.title)}.40`,
body: renderHumanCardMd(card, "medium_40"),
},
})
);
exportsCreated.push(
await prisma.importedExport.create({
data: {
importedSourceId,
exportType: "human_card_11",
title: `${card.title} 11`,
slug: `${slugify(card.title)}.11`,
body: renderHumanCardMd(card, "micro_11"),
},
})
);
}
return { count: exportsCreated.length, exportIds: exportsCreated.map((item) => item.id), }; }
H18) Session stacks as real scripts
This should not be separate authoring. It should compose card exports in order. SessionStack → ordered concept groups → ordered concept cards → each card contributes:
- opener
- explanation
- steps
- prompts
- materials → combined into:
- tutorial.md
- guidebook.md
- live-assistance.md
- youtube-script.md
Good export families for session stacks
- tutorial.md
- process.md
- guidebook.md
- developer-assist.md
- user-assist.md
- youtube-script.md
All of those are just formatting profiles over the same ordered stack content.
So for your concern, yes:
session stacks can become the canonical script-composition layer.
Not just display lists.
H19) Filesystem markdown browser
This should be read-only and secondary.
Purpose
- inspect generated md
- share results inside the app
- create fast navigable documentation
- avoid external platforms
Recommended source
Do not read raw local files first.
Read from ImportedExport first, then optionally mirror to filesystem later.
Safer order DB export record → optional write to /content/generated/skills/*.md → browser reads DB or mirrored files
H110) If you still want md-first very early
markdown file / pasted markdown / fetched page → ImportedSource.normalizedMd → projections
That gives you md-first without forcing all other models to depend on file parsing.
So the early-introduced md structure should be:
- intake format
- projection format
- inspection format
But not yet your only domain truth.
That is the safe compromise.
H111) Suggested routes
External ingest POST /imports/api
VIEWS /imports /imports/[id] /skills /skills/[slug] /scripts /scripts/[slug] /sessions/[slug]
H112) Recommended processing profiles
type ProjectionProfile = | "assistant_skill" | "human_card_90" | "human_card_40" | "human_card_11" | "tutorial" | "process_doc" | "live_assist" | "guidebook" | "youtube_script";
H1ROADMAP — skill.md export layer
Version: V0.2 (codebase-grounded) Replaces: ROADMAP.skill-export.md V0.1 (abstract) Status: Planning — ready to implement
H2What this document is
A grounded implementation plan derived from reading the actual codebase.
Every file path, type, and action name in this document maps to something that already exists.
The goal is to add skill.md export capability — and optionally script.md / session.md —
without touching a single existing file.
H2What already exists (the real picture)
H3Data hierarchy (confirmed from Prisma queries in codebase)
SessionPath
└─ SessionPathItem[] (session-paths/actions/sessionPathActions.ts)
└─ SessionStack (session-stacks/actions/getSessionStack.ts)
└─ SessionConceptGroup[]
└─ ConceptGroup
└─ ConceptGroupItem[]
└─ ConceptCard
└─ CardContent[] (steps[], materials[])
H3What the DB gives you per card (confirmed from getSessionCard.ts)
{
id, title, hook, promise, boundaries,
durationMin, difficulty, domains,
intro, notes,
contents: [{
steps: JsonValue, // JsonArray — use jsonArrayToStrings() to parse
materials: JsonValue,
version, language, structure
}],
conceptGroupItems: [{
conceptGroup: {
title, summary,
sessionConceptGroups: [{ sessionStack }]
}
}]
}
H3What already has a renderer (card-engine.js)
card-engine.js already has renderPoster(), renderStudyCard() etc. as HTML renderers.
The new export layer is the markdown equivalent of what card-engine.js does for HTML.
They are parallel, not competing.
H3What already exists in session-stacks/skills.json
A flat JSON array of concept definitions with: id, title, category, specialty,
summary, durationMinutes, priceEUR, location. These are the source concepts —
the human-authored canonical list. Cards in the DB are generated from / linked to these.
H3The `jsonArrayToStrings()` utility
Lives at @/lib/json. Already used throughout. Must be used in the export layer
whenever reading steps or materials from a CardContent record —
these are stored as Prisma Json fields, not plain string[].
H2What the skill.md template requires (from TEMPLATE.skill.md)
Each output file needs these sections populated from DB fields:
frontmatter:
id ← slugified card.title
title ← card.title
category ← card.domains[0] or conceptGroup.title
realm ← "both" (default)
source ← conceptGroup.title or skills.json specialty
tags ← card.domains (parsed via jsonArrayToStrings)
difficulty ← card.difficulty
frequency ← "daily" | "situational" (inferred from durationMin)
version ← "1.0"
body sections:
tagline ← card.hook
What it is ← card.promise
Why it works ← card.boundaries (repurposed — this field holds constraint/rationale text)
Steps ← content.steps (via jsonArrayToStrings)
Examples ← generated from content.structure.collapsedFrom (if present)
When to use ← content.materials (these often describe context, not just tools)
Prompt starter ← generated template using card.title + card.domains
Related ← other cards in same ConceptGroup
Key mapping note: card.boundaries is the right source for "Why it works" —
it holds the constraint/rationale text authored at creation time.
card.intro and card.notes are freeform editorial fields — do not map these to
fixed template sections; include them only if present as supplementary content.
H2What does NOT need to change
- Zero changes to Prisma schema
- Zero changes to
generateSessionCardsFromText.ts - Zero changes to
createConceptCardManual.ts - Zero changes to
getSessionCard.ts,getSessionStack.ts, or any existing action - Zero changes to any existing page or component
card-engine.jsstays as-is (HTML renderer, parallel not replaced)
H2New directory structure
app/
(pages)/
concept-cards/
actions/
export/
exportConceptCard.ts ← Phase 2
exportConceptGroup.ts ← Phase 3
exportSessionStack.ts ← Phase 3
components/
ExportButton.tsx ← Phase 4
lib/
export/
types.ts ← Phase 1
renderers/
renderSkillMd.ts ← Phase 1 (primary)
renderScriptMd.ts ← Phase 3 (optional)
renderSessionMd.ts ← Phase 3 (optional)
adapters/
toExportableCard.ts ← Phase 1
toExportableGroup.ts ← Phase 2
context/
routeSkillMap.ts ← Phase 5
loadSkillContext.ts ← Phase 5
skills/
_index.json ← Phase 5 (generated)
skill-[slug].md ← Phase 4 (exported, committed)
H2Phase 1 — Core types + single card renderer
Goal: Given one card's data, produce a valid skill.md string.
Risk: Zero. Pure functions. No DB. No routes.
H31.1 — Define `ExportableCard` type
// lib/export/types.ts
export type ExportableCard = {
id: string
title: string
hook: string | null
promise: string | null
boundaries: string | null // maps to "Why it works"
difficulty: string | null
domains: string[] // already parsed strings
durationMin: number
steps: string[] // already parsed via jsonArrayToStrings
materials: string[] // already parsed via jsonArrayToStrings
intro: string | null // freeform — included if present
notes: string | null // freeform — included if present
groupTitle: string | null // from conceptGroupItems[0].conceptGroup.title
groupSummary: string | null
siblingCardTitles: string[] // other cards in same group → related skills
}
export type ExportableGroup = {
id: string
title: string
summary: string | null
cards: ExportableCard[]
}
export type ExportFormat = "skill" | "script" | "session"
H31.2 — Build `toExportableCard` adapter
This takes the raw Prisma result from getSessionCard() and shapes it
into ExportableCard. This is the only place jsonArrayToStrings is called
in the export layer.
// lib/export/adapters/toExportableCard.ts
import { jsonArrayToStrings } from "@/lib/json"
export function toExportableCard(raw: any): ExportableCard {
const content = raw.contents?.[0] ?? null
const groupItem = raw.conceptGroupItems?.[0] ?? null
const group = groupItem?.conceptGroup ?? null
const siblingCardTitles = group?.conceptGroupItems
?.map((item: any) => item.conceptCard?.title)
?.filter((t: string) => t && t !== raw.title)
?? []
return {
id: raw.id,
title: raw.title,
hook: raw.hook ?? null,
promise: raw.promise ?? null,
boundaries: raw.boundaries ?? null,
difficulty: raw.difficulty ?? null,
domains: jsonArrayToStrings(raw.domains ?? []),
durationMin: raw.durationMin ?? 90,
steps: content ? jsonArrayToStrings(content.steps) : [],
materials: content ? jsonArrayToStrings(content.materials) : [],
intro: raw.intro ?? null,
notes: raw.notes ?? null,
groupTitle: group?.title ?? null,
groupSummary: group?.summary ?? null,
siblingCardTitles,
}
}
H31.3 — Build `renderSkillMd` renderer
Pure function. Input: ExportableCard. Output: markdown string matching TEMPLATE.skill.md.
// lib/export/renderers/renderSkillMd.ts
export function renderSkillMd(card: ExportableCard): string {
const slug = slugify(card.title)
const category = card.domains[0] ?? card.groupTitle ?? "General"
const tags = card.domains.length > 0
? card.domains.join(", ")
: "habits, practice"
const frequency = card.durationMin <= 15 ? "daily" : "situational"
const relatedSkills = card.siblingCardTitles
.map(t => `- [\`skill-${slugify(t)}\`] — part of the same concept group`)
.join("\n")
return `---
id: skill-${slug}
title: "${card.title}"
category: "${category}"
realm: both
source: "${card.groupTitle ?? "original concept"}"
tags: [${tags}]
difficulty: ${card.difficulty ?? "beginner"}
frequency: ${frequency}
version: "1.0"
---
# ${card.title}
> ${card.hook ?? card.promise ?? "A practical skill for immediate use."}
## What it is
${card.promise ?? ""}${card.intro ? "\n\n" + card.intro : ""}
## Why it works
${card.boundaries ?? "This skill works because it is grounded in direct practice and repeatable execution."}
## Steps
${card.steps.map((s, i) => `${i + 1}. ${s}`).join("\n")}
## When to use
${card.materials.length > 0
? card.materials.map(m => `- ${m}`).join("\n")
: "- When you want to apply this concept in a real context\n- As part of a structured session or self-directed practice"}
## Prompt starter (copy-ready)
> Apply the **${card.title}** skill to the following situation:
>
> [CONTEXT]
>
> Using the steps above, help me [GOAL]. Be specific, practical, and brief.
${relatedSkills ? `## Related skills\n\n${relatedSkills}\n` : ""}
${card.notes ? `## Notes\n\n${card.notes}\n` : ""}
---
_Source: ${card.groupTitle ?? card.title} — exported from ConceptCard \`${card.id}\`_
`
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
}
Test this phase with a hardcoded ExportableCard fixture before moving to Phase 2.
The output should open cleanly in any markdown viewer and match the TEMPLATE.skill.md structure.
H2Phase 2 — Export server action (single card)
Goal: Wire the adapter + renderer to a server action callable from the UI. Risk: Zero. Read-only Prisma. New file only.
// app/(pages)/concept-cards/actions/export/exportConceptCard.ts
"use server"
import { getSessionCard } from "../getSessionCard"
import { toExportableCard } from "@/lib/export/adapters/toExportableCard"
import { renderSkillMd } from "@/lib/export/renderers/renderSkillMd"
import type { ExportFormat } from "@/lib/export/types"
export async function exportConceptCard(
cardId: string,
format: ExportFormat = "skill"
): Promise<{ markdown: string; filename: string }> {
const raw = await getSessionCard(cardId)
if (!raw) throw new Error(`ConceptCard not found: ${cardId}`)
const card = toExportableCard(raw)
const slug = card.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")
let markdown: string
let filename: string
switch (format) {
case "skill":
default:
markdown = renderSkillMd(card)
filename = `skill-${slug}.md`
}
return { markdown, filename }
}
H2Phase 3 — Group-level export (script.md / session.md)
Goal: Export an entire ConceptGroup or SessionStack as a single document. Risk: Low. Still read-only.
H33.1 — `toExportableGroup` adapter
Fetches a ConceptGroup with all its cards and shapes into ExportableGroup.
Uses getConceptGroups or a dedicated getConceptGroupWithCards query (new, read-only).
H33.2 — `renderScriptMd` renderer
Produces a YouTube / study script from a group:
# [Group title]
## Hook
[first card's hook]
## Introduction
[group summary]
---
## [Card 1 title]
[steps as numbered prose]
## [Card 2 title]
...
---
## Summary + CTA
H33.3 — `renderSessionMd` renderer
Produces a 90-minute coach script from a group, following SessionCard MLV format:
- Hook (≤70 chars)
- Promise (≤120 chars)
- Step-by-step flow (3–7 items, merged across cards)
- Materials list
- Memory aid placeholder
H33.4 — `exportConceptGroup` server action
Same pattern as exportConceptCard but accepts groupId and returns
a multi-card document in the requested format.
H2Phase 4 — Export UI (additive only)
Goal: Surface the export action in the existing card detail page.
Where: concept-cards/[id]/page.tsx — add below the existing QR section.
Risk: Low. Additive UI. No layout changes.
H3Client-side download helper (no new route needed)
// inline in ExportButton.tsx
async function handleExport(cardId: string, format: ExportFormat) {
const { markdown, filename } = await exportConceptCard(cardId, format)
const blob = new Blob([markdown], { type: "text/markdown" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
H3What the button looks like in the page
Add inside the existing SessionCardEditorGate (editor-only, not public):
<ExportButton cardId={card.id} format="skill" label="Export as skill.md" />
<ExportButton cardId={card.id} format="script" label="Export as script.md" />
For group-level export, add to concept-groups/[groupId]/page.tsx the same way.
H2Phase 5 — Context injection for LLM actions
Goal: Make generateSessionCardsFromText (and future AI actions) route-aware
by loading relevant skill.md files as system context.
Risk: Low. One extra await per action. Fully reversible.
H35.1 — Commit exported skills to `/skills/` directory
After Phase 4, export your key concept cards and commit the .md files:
/skills/
skill-ipad-creative-workflow.md
skill-gtd-kanban-mastery.md
skill-pkm-journaling-archives.md
skill-apps-script-processes.md
... (one per entry in skills.json)
_index.json
_index.json is a flat map: { [slug]: { title, category, tags[], path } }
Generated by a one-time script (or manually at first — it's small).
H35.2 — `loadSkillContext` function
// lib/context/loadSkillContext.ts
import fs from "fs/promises"
import path from "path"
export async function loadSkillContext(
slugs: string[],
maxChars = 6000
): Promise<string> {
const skillsDir = path.join(process.cwd(), "skills")
const chunks: string[] = []
let total = 0
for (const slug of slugs) {
if (total >= maxChars) break
try {
const content = await fs.readFile(
path.join(skillsDir, `${slug}.md`), "utf8"
)
chunks.push(content)
total += content.length
} catch {
// file not found — skip silently
}
}
return chunks.join("\n\n---\n\n")
}
H35.3 — Route skill map
// lib/context/routeSkillMap.ts
export const routeSkillMap: Record<string, string[]> = {
"/concept-cards": ["skill-gtd-kanban-mastery", "skill-pkm-journaling-archives"],
"/concept-cards/new": ["skill-ipad-creative-workflow"],
"/concept-groups": ["skill-gtd-kanban-mastery"],
"/session-stacks": ["skill-apps-script-processes"],
"/session-paths": [],
}
Add slugs as you export and commit more cards. This is the only ongoing maintenance.
H35.4 — Wire into `generateSessionCardsFromText`
Minimal change to the existing action — add 3 lines before the OpenAI call:
// in generateSessionCardsFromText.ts — add before openai.chat.completions.create()
import { loadSkillContext } from "@/lib/context/loadSkillContext"
import { routeSkillMap } from "@/lib/context/routeSkillMap"
// inside the function, before the API call:
const skillSlugs = routeSkillMap[input.contextKey ?? "/concept-cards"] ?? []
const skillContext = skillSlugs.length > 0
? await loadSkillContext(skillSlugs)
: ""
// then update the system message:
{
role: "system",
content: skillContext
? `You are a precise cognitive knowledge architect.\n\nActive skill context:\n---\n${skillContext}\n---`
: "You are a precise cognitive knowledge architect.",
}
The contextKey field already exists in GenerateSessionCardsInput as an optional field.
It is not yet used — this wires it up for the first time.
H2Phase 6 — Claude API (optional swap, when ready)
The context injection pattern is model-agnostic. When switching from OpenAI to Claude:
// replace the fetch call in generateSessionCardsFromText.ts
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 1000,
system: skillContext
? `You are a precise cognitive knowledge architect.\n\nActive skill context:\n---\n${skillContext}\n---`
: "You are a precise cognitive knowledge architect.",
messages: [{ role: "user", content: buildSessionCardPrompt(text) }],
}),
})
const data = await response.json()
const output = data.content?.find((b: any) => b.type === "text")?.text ?? ""
Everything else stays identical. The buildSessionCardPrompt function is unchanged.
The safeParseJson / normalizeSessionCardPayload helpers are unchanged.
H2Delivery order
| Phase | What | New files | Touches existing? |
|---|---|---|---|
| 1 | Types + toExportableCard + renderSkillMd | 3 | No |
| 2 | exportConceptCard server action | 1 | No |
| 3 | Group-level adapters + renderers | 3–4 | No |
| 4 | ExportButton UI component | 1 | Add 2 lines to existing pages |
| 5 | /skills/ directory + context loader + route map | 3 + .md files | Add 3 lines to 1 existing action |
| 6 | Claude API swap | 0 | Replace 1 function body |
H2The first thing to build (right now)
lib/export/renderers/renderSkillMd.ts
Write it. Test it with this hardcoded fixture:
const fixture: ExportableCard = {
id: "abc123",
title: "GTD & Kanban Mastery",
hook: "Stop managing tasks. Start managing flow.",
promise: "Design a personal productivity system that actually sticks.",
boundaries: "Works because it externalises cognitive load and makes priorities visible at a glance.",
difficulty: "intermediate",
domains: ["productivity", "systems", "habits"],
durationMin: 90,
steps: [
"Capture everything into a single trusted inbox",
"Process each item: do, defer, delegate, or delete",
"Assign to a Kanban column: backlog, this week, today, done",
"Review the board every morning for 5 minutes",
"Do a weekly review to clear and reset",
],
materials: ["Notion or physical board", "Weekly review template", "Timer"],
intro: null,
notes: null,
groupTitle: "Productivity Systems",
groupSummary: null,
siblingCardTitles: ["Notes, Archives & Journaling", "PDF & Paper Hybrid Systems"],
}
console.log(renderSkillMd(fixture))
The output should open cleanly as markdown and match TEMPLATE.skill.md section for section.
When it does, Phase 1 is done.
ROADMAP V0.2 — grounded in codebase read on 2026-04-20
Replaces the abstract V0.1 written before code inspection
Next step: implement lib/export/renderers/renderSkillMd.ts with the fixture above