diff --git a/.changeset/import-as-template.md b/.changeset/import-as-template.md
new file mode 100644
index 00000000..ca654c40
--- /dev/null
+++ b/.changeset/import-as-template.md
@@ -0,0 +1,5 @@
+---
+'@inkeep/open-knowledge': patch
+---
+
+Add "Import as template" to the File Tree context menu. Right-click a markdown file to copy it into the folder's `.ok/templates/`, with the option to keep the original or convert it (delete the source). Backed by a new `POST /api/template/import` endpoint that carries over the source frontmatter.
diff --git a/packages/app/src/components/FileTree.tsx b/packages/app/src/components/FileTree.tsx
index 2c6f62a5..b989e37c 100644
--- a/packages/app/src/components/FileTree.tsx
+++ b/packages/app/src/components/FileTree.tsx
@@ -29,6 +29,7 @@ import {
Copy,
CopyPlus,
EyeOff,
+ FileKey,
FilePlus,
FolderOpen,
FolderPlus,
@@ -195,6 +196,7 @@ import {
subscribeToFileTreeMenuActionDuplicate,
subscribeToFileTreeMenuActionRename,
} from '@/lib/file-tree-menu-action-events';
+import { importTemplate } from '@/lib/folder-config-api';
import { parseServerResponse, parseSuccessOrWarn } from '@/lib/parse-server-response';
import { createRefreshScheduler } from '@/lib/refresh-scheduler';
import { getRelaunchInFlightSnapshot, useRelaunchInFlight } from '@/lib/relaunch-store';
@@ -572,6 +574,7 @@ interface FileTreeMenuProps {
* Drives the folder menu's "New from template" hover submenu. */
onCreateFromTemplate: (parentDir: string, templateName: string) => void;
onDuplicate: (target: FileTreeTarget) => void;
+ onImportTemplate: (target: FileTreeTarget, deleteSource: boolean) => void;
onDelete: (targets: FileTreeTarget[]) => void;
onExpandSubtree: (treePath: string) => void;
onCollapseSubtree: (treePath: string) => void;
@@ -755,6 +758,7 @@ function FileTreeMenu({
onStartCreating,
onCreateFromTemplate,
onDuplicate,
+ onImportTemplate,
onDelete,
onExpandSubtree,
onCollapseSubtree,
@@ -1134,16 +1138,44 @@ function FileTreeMenu({
<>
{!isAsset ? (
- {
- close();
- onDuplicate(target);
- }}
- >
-
- Duplicate
-
+ <>
+
+
+
+ Import as template
+
+
+ {
+ close();
+ onImportTemplate(target, false);
+ }}
+ >
+ Keep original file
+
+ {
+ close();
+ onImportTemplate(target, true);
+ }}
+ >
+ Convert (delete original)
+
+
+
+ {
+ close();
+ onDuplicate(target);
+ }}
+ >
+
+ Duplicate
+
+ >
) : null}
{
+ setBusyPath(null);
+ busyPathRef.current = null;
+ };
+ busyPathRef.current = target.path;
+ setBusyPath(target.path);
+ setError(null);
+
+ const appPath = target.path;
+ const slash = appPath.lastIndexOf('/');
+ const targetFolder = slash === -1 ? '' : appPath.slice(0, slash);
+
+ const res = await importTemplate({
+ sourcePath: target.path,
+ targetFolder,
+ deleteSource,
+ });
+
+ if (!res.ok) {
+ toast.error(t`Failed to import template`, { description: res.error });
+ clearBusyState();
+ return;
+ }
+
+ if (deleteSource) {
+ await applyDeleteAftermath([target], [target.path], []);
+ // Optimistically remove from view if deleted, standard watcher sweeps later
+ setDocuments((current) => {
+ const next = current.filter(
+ (entry) => !(isDocumentEntry(entry) && entry.docName === target.path),
+ );
+ resetModelToDocuments(next);
+ markNextDocumentsAsApplied(next);
+ return next;
+ });
+ emitDocumentsChanged(['files', 'backlinks', 'graph']);
+ }
+
+ toast.success(t`Template imported`, {
+ description: res.path,
+ });
+ clearBusyState();
+ }
+
function recoverMarkdownRenameConflict(message: string): boolean {
const bareDestinationPath = parseAlreadyExistsRenamePath(message);
if (!bareDestinationPath || markdownTreeExtension(bareDestinationPath)) return false;
@@ -4446,6 +4525,7 @@ export function FileTree({
startCreating('file', parentDir, { template: templateName })
}
onDuplicate={handleDuplicateTarget}
+ onImportTemplate={handleImportTemplate}
onDelete={(targets) => setDeleteRequest({ targets })}
onExpandSubtree={expandSubtree}
onCollapseSubtree={collapseSubtree}
diff --git a/packages/app/src/lib/folder-config-api.ts b/packages/app/src/lib/folder-config-api.ts
index 258b3d7f..951374b2 100644
--- a/packages/app/src/lib/folder-config-api.ts
+++ b/packages/app/src/lib/folder-config-api.ts
@@ -164,3 +164,41 @@ export async function moveTemplate(input: {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
+
+/**
+ * POST `/api/template/import` — import an existing markdown document as a template.
+ */
+export async function importTemplate(input: {
+ sourcePath: string;
+ targetFolder: string;
+ name?: string;
+ title?: string;
+ deleteSource?: boolean;
+}): Promise<
+ { ok: true; path: string; created: boolean; warnings: string[] } | { ok: false; error: string }
+> {
+ try {
+ const res = await fetch('/api/template/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(input),
+ });
+ if (!res.ok) {
+ return { ok: false, error: await readErrorBody(res) };
+ }
+ const payload = (await res.json().catch(() => null)) as {
+ path?: string;
+ created?: boolean;
+ warnings?: string[];
+ } | null;
+ emitTemplatesChanged();
+ return {
+ ok: true,
+ path: payload?.path ?? '',
+ created: payload?.created ?? false,
+ warnings: payload?.warnings ?? [],
+ };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+}
diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json
index bb710461..32c8aac0 100644
--- a/packages/app/src/locales/en/messages.json
+++ b/packages/app/src/locales/en/messages.json
@@ -11,6 +11,7 @@
"-3ois8": ["Hard break"],
"-5jISU": [["0", "plural", { "one": ["#", " template"], "other": ["#", " templates"] }]],
"-8PP0T": ["Match case"],
+ "-FeyRg": ["Keep original file"],
"-K0AvT": ["Disconnect"],
"-LHLx7": ["edited template"],
"-QCLWk": ["Templates available"],
@@ -549,6 +550,7 @@
"BQu9Pg": ["Symlink"],
"BUyQnq": ["Couldn't restart the server. Try `ok start` in this folder."],
"BY-g4_": ["Add pattern"],
+ "Ba4D2x": ["Template imported"],
"Bdha5y": ["Publishing..."],
"BeWTQy": ["Loading starter packs"],
"BgV29F": ["Visual editor find input focused"],
@@ -1168,6 +1170,7 @@
"PR9FSv": ["Move to the next visual-editor find result."],
"PRnH8G": ["of ", ["0"]],
"PVUn_c": ["Open folder"],
+ "PYmp-0": ["Import as template"],
"PZtgO0": ["Opening worktree"],
"P_bX4O": ["How to set up"],
"Pck6eI": [
@@ -1783,6 +1786,7 @@
"dOHMA0": ["Failed to move"],
"dOxPd4": ["Footnote"],
"dPOFid": ["Show or hide terminal"],
+ "dQHSCs": ["Convert (delete original)"],
"dTaauv": ["Reason: ", ["reason"]],
"dUCQo4": ["Renamed to \"", ["trimmed"], "\""],
"dUQ7_J": [
@@ -2406,6 +2410,7 @@
"rD-woT": [
"Tell us what went wrong and we'll gather the logs. Nothing leaves your Mac until you've reviewed it."
],
+ "rDOuNw": ["Failed to import template"],
"rFmBG3": ["Color theme"],
"rFw02Q": ["Uploading ", ["keyName"]],
"rJVoVX": ["Source find results"],
diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po
index 62f3f255..bfbddd0b 100644
--- a/packages/app/src/locales/en/messages.po
+++ b/packages/app/src/locales/en/messages.po
@@ -1891,6 +1891,10 @@ msgstr "Content root"
msgid "Content rules not yet loaded — try again in a moment"
msgstr "Content rules not yet loaded — try again in a moment"
+#: src/components/FileTree.tsx
+msgid "Convert (delete original)"
+msgstr "Convert (delete original)"
+
#: src/editor/bubble-menu/FootnoteBubbleButton.tsx
msgid "Convert selection to footnote"
msgstr "Convert selection to footnote"
@@ -3286,6 +3290,10 @@ msgstr "Failed to enable semantic search — {detail}"
msgid "Failed to enable sync — {detail}"
msgstr "Failed to enable sync — {detail}"
+#: src/components/FileTree.tsx
+msgid "Failed to import template"
+msgstr "Failed to import template"
+
#: src/components/ActivityModeContent.tsx
msgid "Failed to load activity"
msgstr "Failed to load activity"
@@ -4046,6 +4054,10 @@ msgstr "Ignore patterns (raw text)"
msgid "Import"
msgstr "Import"
+#: src/components/FileTree.tsx
+msgid "Import as template"
+msgstr "Import as template"
+
#: src/components/SeedDialog.tsx
msgid "In a subfolder"
msgstr "In a subfolder"
@@ -4304,6 +4316,10 @@ msgstr "Keep file deleted"
msgid "Keep my version"
msgstr "Keep my version"
+#: src/components/FileTree.tsx
+msgid "Keep original file"
+msgstr "Keep original file"
+
#: src/components/SeedDialog.tsx
msgid "Keep track of the people and companies you work with and the meetings you have with them. Good for remembering who someone is, how you know them, and what you last talked about."
msgstr "Keep track of the people and companies you work with and the meetings you have with them. Good for remembering who someone is, how you know them, and what you last talked about."
@@ -7638,6 +7654,10 @@ msgstr "template \"{name}\""
msgid "Template \"{trimmedTitle}\" created"
msgstr "Template \"{trimmedTitle}\" created"
+#: src/components/FileTree.tsx
+msgid "Template imported"
+msgstr "Template imported"
+
#: src/components/empty-state/CreateView.tsx
msgid "Template list"
msgstr "Template list"
diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json
index 6ee04960..f64bf021 100644
--- a/packages/app/src/locales/pseudo/messages.json
+++ b/packages/app/src/locales/pseudo/messages.json
@@ -11,6 +11,7 @@
"-3ois8": ["Ĥàŕď ƀŕēàķ"],
"-5jISU": [["0", "plural", { "one": ["#", " ţēḿƥĺàţē"], "other": ["#", " ţēḿƥĺàţēś"] }]],
"-8PP0T": ["Ḿàţćĥ ćàśē"],
+ "-FeyRg": ["Ķēēƥ ōŕĩĝĩńàĺ ƒĩĺē"],
"-K0AvT": ["Ďĩśćōńńēćţ"],
"-LHLx7": ["ēďĩţēď ţēḿƥĺàţē"],
"-QCLWk": ["Ţēḿƥĺàţēś àvàĩĺàƀĺē"],
@@ -549,6 +550,7 @@
"BQu9Pg": ["Śŷḿĺĩńķ"],
"BUyQnq": ["Ćōũĺďń'ţ ŕēśţàŕţ ţĥē śēŕvēŕ. Ţŕŷ `ōķ śţàŕţ` ĩń ţĥĩś ƒōĺďēŕ."],
"BY-g4_": ["Àďď ƥàţţēŕń"],
+ "Ba4D2x": ["Ţēḿƥĺàţē ĩḿƥōŕţēď"],
"Bdha5y": ["Ƥũƀĺĩśĥĩńĝ..."],
"BeWTQy": ["Ĺōàďĩńĝ śţàŕţēŕ ƥàćķś"],
"BgV29F": ["Vĩśũàĺ ēďĩţōŕ ƒĩńď ĩńƥũţ ƒōćũśēď"],
@@ -1168,6 +1170,7 @@
"PR9FSv": ["Ḿōvē ţō ţĥē ńēxţ vĩśũàĺ-ēďĩţōŕ ƒĩńď ŕēśũĺţ."],
"PRnH8G": ["ōƒ ", ["0"]],
"PVUn_c": ["Ōƥēń ƒōĺďēŕ"],
+ "PYmp-0": ["Ĩḿƥōŕţ àś ţēḿƥĺàţē"],
"PZtgO0": ["Ōƥēńĩńĝ ŵōŕķţŕēē"],
"P_bX4O": ["Ĥōŵ ţō śēţ ũƥ"],
"Pck6eI": [
@@ -1783,6 +1786,7 @@
"dOHMA0": ["Ƒàĩĺēď ţō ḿōvē"],
"dOxPd4": ["Ƒōōţńōţē"],
"dPOFid": ["Śĥōŵ ōŕ ĥĩďē ţēŕḿĩńàĺ"],
+ "dQHSCs": ["Ćōńvēŕţ (ďēĺēţē ōŕĩĝĩńàĺ)"],
"dTaauv": ["Ŕēàśōń: ", ["reason"]],
"dUCQo4": ["Ŕēńàḿēď ţō \"", ["trimmed"], "\""],
"dUQ7_J": [
@@ -2406,6 +2410,7 @@
"rD-woT": [
"Ţēĺĺ ũś ŵĥàţ ŵēńţ ŵŕōńĝ àńď ŵē'ĺĺ ĝàţĥēŕ ţĥē ĺōĝś. Ńōţĥĩńĝ ĺēàvēś ŷōũŕ Ḿàć ũńţĩĺ ŷōũ'vē ŕēvĩēŵēď ĩţ."
],
+ "rDOuNw": ["Ƒàĩĺēď ţō ĩḿƥōŕţ ţēḿƥĺàţē"],
"rFmBG3": ["Ćōĺōŕ ţĥēḿē"],
"rFw02Q": ["Ũƥĺōàďĩńĝ ", ["keyName"]],
"rJVoVX": ["Śōũŕćē ƒĩńď ŕēśũĺţś"],
diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po
index 2d0c30cd..fb05688a 100644
--- a/packages/app/src/locales/pseudo/messages.po
+++ b/packages/app/src/locales/pseudo/messages.po
@@ -1887,6 +1887,10 @@ msgstr ""
msgid "Content rules not yet loaded — try again in a moment"
msgstr ""
+#: src/components/FileTree.tsx
+msgid "Convert (delete original)"
+msgstr ""
+
#: src/editor/bubble-menu/FootnoteBubbleButton.tsx
msgid "Convert selection to footnote"
msgstr ""
@@ -3282,6 +3286,10 @@ msgstr ""
msgid "Failed to enable sync — {detail}"
msgstr ""
+#: src/components/FileTree.tsx
+msgid "Failed to import template"
+msgstr ""
+
#: src/components/ActivityModeContent.tsx
msgid "Failed to load activity"
msgstr ""
@@ -4042,6 +4050,10 @@ msgstr ""
msgid "Import"
msgstr ""
+#: src/components/FileTree.tsx
+msgid "Import as template"
+msgstr ""
+
#: src/components/SeedDialog.tsx
msgid "In a subfolder"
msgstr ""
@@ -4300,6 +4312,10 @@ msgstr ""
msgid "Keep my version"
msgstr ""
+#: src/components/FileTree.tsx
+msgid "Keep original file"
+msgstr ""
+
#: src/components/SeedDialog.tsx
msgid "Keep track of the people and companies you work with and the meetings you have with them. Good for remembering who someone is, how you know them, and what you last talked about."
msgstr ""
@@ -7634,6 +7650,10 @@ msgstr ""
msgid "Template \"{trimmedTitle}\" created"
msgstr ""
+#: src/components/FileTree.tsx
+msgid "Template imported"
+msgstr ""
+
#: src/components/empty-state/CreateView.tsx
msgid "Template list"
msgstr ""
diff --git a/packages/app/tests/integration/attribution-sweep-coverage.test.ts b/packages/app/tests/integration/attribution-sweep-coverage.test.ts
index accea3f1..da42fb57 100644
--- a/packages/app/tests/integration/attribution-sweep-coverage.test.ts
+++ b/packages/app/tests/integration/attribution-sweep-coverage.test.ts
@@ -130,6 +130,13 @@ const EXEMPT_HANDLERS = new Set([
// other read handlers below.
'handleLintDoc',
'handleLintAudit',
+ // `/api/template/import` — imports an existing doc as a template. Same
+ // project-config posture as `handleTemplate`; it DOES thread
+ // `extractActorIdentity` (folder timeline) + `extractAgentIdentity` (template
+ // write session), but is exempt from the identity-required sweep because the
+ // single-file-mode guard emits before identity extraction — same rationale as
+ // the sibling template handlers.
+ 'handleTemplateImport',
// `/api/templates` — project-wide flat enumeration of every template
// (read-only). Returns the union of all `/.ok/templates/*.md`;
// same rationale as `handleTagsList` — read path, no agent identity.
diff --git a/packages/app/tests/integration/conflict-gate-coverage.test.ts b/packages/app/tests/integration/conflict-gate-coverage.test.ts
index 94e0b077..935b0ac2 100644
--- a/packages/app/tests/integration/conflict-gate-coverage.test.ts
+++ b/packages/app/tests/integration/conflict-gate-coverage.test.ts
@@ -51,6 +51,10 @@ const REQUIRED_HANDLERS = [
'handleTemplatePut',
'handleTemplateDelete',
'handleTemplateMove',
+ // `/api/template/import` — copies a source doc into `.ok/templates/` and,
+ // when `deleteSource`, removes the source. The source-delete branch gates via
+ // `respondDocInConflict`; the template target gates via `checkTemplateConflictGate`.
+ 'handleTemplateImport',
// Skill CONTENT-doc writers (skills-as-content): a PROJECT `SKILL.md` and its
// `.md` references are real CRDT content docs, so their CRDT paired-write
// path must refuse a mid-conflict doc via `checkSkillDocConflictGate`. The
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 27316654..7972fc18 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -940,6 +940,10 @@ export {
TemplateFrontmatterSchema,
type TemplateGetSuccess,
TemplateGetSuccessSchema,
+ type TemplateImportRequest,
+ TemplateImportRequestSchema,
+ type TemplateImportSuccess,
+ TemplateImportSuccessSchema,
type TemplateMoveRequest,
TemplateMoveRequestSchema,
type TemplateMoveSuccess,
diff --git a/packages/core/src/schemas/api/tags-search.ts b/packages/core/src/schemas/api/tags-search.ts
index df169703..bf268c93 100644
--- a/packages/core/src/schemas/api/tags-search.ts
+++ b/packages/core/src/schemas/api/tags-search.ts
@@ -254,6 +254,37 @@ export const TemplatePutSuccessSchema = z
.strict() satisfies StandardSchemaV1;
export type TemplatePutSuccess = z.infer;
+/**
+ * Request body for `POST /api/template/import`. `sourcePath` is the relative
+ * path to the source markdown document, `targetFolder` is the folder where the
+ * template should be saved, `name` is the optional template name, and `title`
+ * is the optional template title. `deleteSource` is optional to support move-instead-of-copy.
+ */
+export const TemplateImportRequestSchema = z
+ .object({
+ sourcePath: z.string().min(1),
+ targetFolder: z.string(),
+ name: z.string().optional(),
+ title: z.string().optional(),
+ deleteSource: z.boolean().optional(),
+ ...agentIdentityFields,
+ summary: summaryField,
+ })
+ .strict() satisfies StandardSchemaV1;
+export type TemplateImportRequest = z.infer;
+
+/**
+ * Success body for `POST /api/template/import`.
+ */
+export const TemplateImportSuccessSchema = z
+ .object({
+ path: z.string().min(1),
+ created: z.boolean(),
+ warnings: z.array(z.string()),
+ })
+ .strict() satisfies StandardSchemaV1;
+export type TemplateImportSuccess = z.infer;
+
/**
* Success body for `DELETE /api/template?name=&folder=`. `existed` is
* `true` when the file was deleted; `false` when the operation was a no-op
diff --git a/packages/server/src/api-extension.ts b/packages/server/src/api-extension.ts
index 14eb61c3..379d5da1 100644
--- a/packages/server/src/api-extension.ts
+++ b/packages/server/src/api-extension.ts
@@ -217,6 +217,8 @@ import {
TagsListSuccessSchema,
TemplateDeleteSuccessSchema,
TemplateGetSuccessSchema,
+ TemplateImportRequestSchema,
+ TemplateImportSuccessSchema,
TemplateMoveRequestSchema,
TemplateMoveSuccessSchema,
TemplatePutRequestSchema,
@@ -247,7 +249,7 @@ import {
} from '@inkeep/open-knowledge-core/shadow-repo-layout';
import busboy from 'busboy';
import { fileTypeFromBuffer } from 'file-type';
-import { parse as parseYaml } from 'yaml';
+import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { z } from 'zod';
import { captureEffect } from './activity-log.ts';
import { listAgentActivity, synthesizeStackItemDiffText } from './agent-activity.ts';
@@ -13356,7 +13358,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension {
function checkTemplateConflictGate(
folder: string,
name: string,
- handler: 'template-put' | 'template-delete' | 'template-move',
+ handler: 'template-put' | 'template-delete' | 'template-move' | 'template-import',
res: ServerResponse,
): boolean {
if (!name) return false;
@@ -13948,6 +13950,261 @@ export function createApiExtension(options: ApiExtensionOptions): Extension {
{ handler: 'template-move', method: 'POST' },
);
+ const handleTemplateImport = withValidation(
+ TemplateImportRequestSchema,
+ async (_req, res, body) => {
+ try {
+ if (ephemeral) {
+ errorResponse(
+ res,
+ 403,
+ 'urn:ok:error:single-file-mode',
+ 'Templates are not available in single-file mode.',
+ { handler: 'template-import' },
+ );
+ return;
+ }
+
+ const actor = extractActorIdentity(
+ body as unknown as Record,
+ getPrincipal,
+ );
+ if (actor.kind === 'invalid-summary') {
+ errorResponse(res, 400, 'urn:ok:error:invalid-request', 'Summary must be a string.', {
+ handler: 'template-import',
+ });
+ return;
+ }
+
+ const sourcePath = body.sourcePath;
+ if (!isSafeDocName(sourcePath)) {
+ errorResponse(res, 400, 'urn:ok:error:invalid-request', 'Invalid sourcePath.', {
+ handler: 'template-import',
+ });
+ return;
+ }
+
+ const sourceDocName = resolveAlias(sourcePath);
+ if (isSystemDoc(sourceDocName) || isConfigDoc(sourceDocName)) {
+ errorResponse(
+ res,
+ 400,
+ 'urn:ok:error:reserved-doc-name',
+ `'${sourceDocName}' is a reserved document name.`,
+ { handler: 'template-import' },
+ );
+ return;
+ }
+
+ const sourceFilePath = resolveContentEntryPath(contentDir, 'file', sourceDocName);
+ if (!existsSync(sourceFilePath)) {
+ errorResponse(
+ res,
+ 404,
+ 'urn:ok:error:doc-not-found',
+ `Source document not found: ${sourceDocName}.`,
+ {
+ handler: 'template-import',
+ },
+ );
+ return;
+ }
+
+ const existing = hocuspocus.documents.get(sourceDocName);
+ if (body.deleteSource) {
+ const deleteEngine = getSyncEngine?.();
+ const deleteTrackedFiles = new Set(
+ deleteEngine ? deleteEngine.getConflicts().map((c) => c.file) : [],
+ );
+ const conflictedByLifecycle = existing !== undefined && isDocInConflict(existing);
+ const conflictedByStore = deleteTrackedFiles.has(sourcePath);
+ if (conflictedByLifecycle || conflictedByStore) {
+ respondDocInConflict(
+ res,
+ new DocInConflictError({ file: sourcePath }),
+ 'template-import',
+ );
+ return;
+ }
+ }
+
+ // Read source content
+ let sourceContent = '';
+ if (existing) {
+ sourceContent = existing.getText('source').toString();
+ } else {
+ const dc = await hocuspocus.openDirectConnection(sourceDocName);
+ try {
+ const document = dc.document;
+ if (!document) {
+ errorResponse(
+ res,
+ 500,
+ 'urn:ok:error:doc-not-available',
+ 'Source document is not available.',
+ {
+ handler: 'template-import',
+ },
+ );
+ return;
+ }
+ sourceContent = document.getText('source').toString();
+ } finally {
+ await dc.disconnect();
+ }
+ }
+
+ // Determine target template name
+ let name = body.name;
+ if (!name) {
+ const { basename } = splitContentPath(sourcePath);
+ const nameWithoutExt = basename.replace(/\.(md|mdx)$/i, '');
+ name = nameWithoutExt.replace(/[^A-Za-z0-9_-]/g, '-').toLowerCase();
+ name = name.replace(/^[-_]+|[-_]+$/g, '');
+ name ||= 'imported-template';
+ }
+
+ if (!validateTemplateName(name, res, 'template-import')) return;
+
+ const validated = validateFolderRel(body.targetFolder, res, 'folder', 'template-import');
+ if (!validated) return;
+
+ if (checkTemplateConflictGate(validated.folderRel, name, 'template-import', res)) return;
+
+ // Parse existing frontmatter of the source file to extract the title/description/tags
+ const { frontmatter: sourceFmText, body: sourceBody } = stripFrontmatter(sourceContent);
+ const cleanFmText = unwrapFrontmatterFences(sourceFmText);
+ let sourceFmObj: Record = {};
+ try {
+ if (cleanFmText.trim()) {
+ sourceFmObj = parseYaml(cleanFmText) as Record;
+ }
+ } catch {
+ // Malformed frontmatter — treat the source as having none.
+ }
+
+ const templateTitle =
+ body.title || (sourceFmObj?.title as string) || extractPageTitle(sourceContent, name);
+ const templateDescription = (sourceFmObj?.description as string) || '';
+ const templateTags = Array.isArray(sourceFmObj?.tags) ? (sourceFmObj.tags as string[]) : [];
+
+ // For the starter content, we can use the original document frontmatter but remove `template:`
+ // if it somehow got there. Keep other fields.
+ const starterFmObj = { ...sourceFmObj };
+ delete starterFmObj.template;
+
+ let starterContent = '';
+ if (Object.keys(starterFmObj).length > 0) {
+ const fmYaml = stringifyYaml(starterFmObj);
+ starterContent = fmYaml.trim() + '\n';
+ }
+ starterContent = starterContent ? `---\n${starterContent}---\n${sourceBody}` : sourceBody;
+
+ const composed = composeTemplateContent({
+ name,
+ body: starterContent,
+ frontmatter: {
+ title: templateTitle,
+ description: templateDescription,
+ tags: templateTags,
+ },
+ });
+
+ if (!composed.ok) {
+ errorResponse(res, 400, 'urn:ok:error:invalid-request', 'Invalid template request.', {
+ handler: 'template-import',
+ detail: composed.error.code,
+ cause: new Error(composed.error.message),
+ });
+ return;
+ }
+
+ const templateFilePath = resolve(
+ validated.resolvedContentDir,
+ validated.folderRel,
+ '.ok',
+ 'templates',
+ `${name}.md`,
+ );
+ const templateCreated = !existsSync(templateFilePath);
+ const templateRelPath = relative(validated.resolvedContentDir, templateFilePath)
+ .split(/[\\/]/)
+ .filter(Boolean)
+ .join('/');
+ const templateDocName = templateDocNameFor(validated.folderRel, name);
+
+ const { agentId, agentName, colorSeed, clientName } = extractAgentIdentity(
+ body as unknown as Record,
+ );
+ const templateSession = await sessionManager.getSession(templateDocName, agentId, {
+ displayName: agentName,
+ colorSeed,
+ clientName,
+ });
+ templateSession.dc.document.transact(() => {
+ composeAndWriteRawBody(templateSession.dc.document, composed.content, 'agent');
+ }, templateSession.origin);
+
+ const templateFlush = await flushDiskAndDetectOutcome(templateDocName);
+ if (templateFlush?.kind === 'failure') {
+ respondPersistenceFailure(res, templateFlush.failure, 'template-import');
+ return;
+ }
+ if (templateFlush?.kind === 'divergence') {
+ respondDiskDivergence(res, 'template-import');
+ return;
+ }
+
+ attributeOkArtifactWrite(
+ actor,
+ okArtifactKey('template', validated.folderRel, name),
+ `template-import: ${templateRelPath}`,
+ );
+
+ if (body.deleteSource) {
+ const deletedDocNames = [sourceDocName];
+ await captureAndCloseDocuments(deletedDocNames, 'deleted-upstream');
+ if (recentlyRemovedDocs) {
+ recentlyRemovedDocs.setDeleted(sourceDocName);
+ }
+ tracedUnlinkSync(sourceFilePath);
+ mutateFileIndex?.({
+ kind: 'delete',
+ path: sourceFilePath,
+ docName: sourceDocName,
+ });
+ }
+
+ await commitOkArtifactWrite('template-import');
+ signalChannel?.('files');
+
+ successResponse(
+ res,
+ 200,
+ TemplateImportSuccessSchema,
+ {
+ path: templateRelPath,
+ created: templateCreated,
+ warnings: composed.warnings,
+ },
+ { handler: 'template-import' },
+ );
+ } catch (e) {
+ errorResponse(
+ res,
+ 500,
+ 'urn:ok:error:internal-server-error',
+ 'Failed to import template.',
+ {
+ handler: 'template-import',
+ cause: e,
+ },
+ );
+ }
+ },
+ { handler: 'template-import', method: 'POST' },
+ );
+
// ─── Skills (`/api/skill`, `/api/skills`) ──────────────────────
//
// Skills are fs-direct `.ok/skills//` artifacts (SKILL.md + optional
@@ -18271,6 +18528,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension {
'/api/pages': handlePages,
'/api/folder-config': handleFolderConfig,
'/api/template': handleTemplate,
+ '/api/template/import': handleTemplateImport,
'/api/templates': handleTemplatesList,
'/api/skill': handleSkill,
'/api/skill-file': handleSkillFile,
@@ -18387,6 +18645,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension {
'/api/install-skill',
'/api/folder-config',
'/api/template',
+ '/api/template/import',
'/api/skill',
'/api/skill-file',
'/api/skill/install',