Skip to content

Custom targets

@atomprd/codegen is target-agnostic. The reference is @atomprd/codegen-senlang; you can write your own.

import type {
CodegenContext,
CodegenTemplate,
OutputFile,
} from "@atomprd/codegen";
export const myTemplate: CodegenTemplate = {
name: "my-stack",
async scaffold(ctx) {
return [
{ path: "package.json",
contents: JSON.stringify({
name: "generated-app",
dependencies: { "vue": "^3.4.0" }
}, null, 2),
generated: true },
];
},
async structural(ctx) {
const out: OutputFile[] = [];
for (const atom of ctx.doc.manifest.atoms) {
if (atom.kind === "entity") {
out.push({
path: `src/entities/${atom.name}.ts`,
contents: emitEntity(atom),
generated: true,
});
}
if (atom.kind === "feature" && atom.behavior) {
out.push({
path: `src/features/${slug(atom.name)}.ts`,
contents: emitFeature(atom),
generated: true,
});
}
}
return out;
},
async behavioral(ctx) {
return []; // Defer or call ctx.llm
},
};
import { runTemplate } from "@atomprd/codegen";
import { myTemplate } from "./my-template";
import { PrdDocument } from "@atomprd/core";
import manifest from "./atoms/manifest.json" assert { type: "json" };
const doc = PrdDocument.load(manifest);
const result = await runTemplate(myTemplate, {
doc,
outDir: "./out",
});
console.log(`Wrote ${result.files.length} files`);

The 5 atom kinds carrying Layer 2 are where most of your structural work lives. Pseudocode for a Vue + Pinia target:

function lowerStepToVue(step: Step): string {
switch (step.kind) {
case "validate":
return `if (!validators.${step.rule}(state)) throw new Error(${JSON.stringify(step.errorMessage ?? "")});`;
case "call":
return `const ${step.assignTo ?? "_"} = await api.${step.method?.toLowerCase() ?? "get"}(\`${step.endpoint}\`, ${JSON.stringify(step.body ?? null)});`;
case "dispatch":
return `await features.${slug(step.feature)}(${JSON.stringify(step.args ?? {})});`;
case "setState":
return `state.${step.path.replace(/^state\./, "")} = ${JSON.stringify(step.value)};`;
case "navigate":
return `router.push({ path: ${JSON.stringify(step.to)}, query: ${JSON.stringify(step.params ?? {})} });`;
case "log":
return `console.${step.level}(${JSON.stringify(step.msg)});`;
case "if":
return `if (${step.condition}) { ${step.then.map(lowerStepToVue).join(" ")} }${step.else ? ` else { ${step.else.map(lowerStepToVue).join(" ")} }` : ""}`;
}
}

Same pattern for Section → Vue SFC, EnforcedAt → Pinia store guard, before_after → Vitest scenario.

Mark deterministic output generated: true. The driver respects user edits on generated: false files (e.g. package.json after a user added a custom dep). Re-run is safe.

The reference round-trip fixture is atomprd/examples/habit-tracker/ — 21 atoms, fully filled Layer 2, demonstrating all 4 patterns.

Terminal window
cd atomprd/examples/habit-tracker
bun run gen # runs codegen-senlang, writes out/
diff -r out/ <expected-snapshot>

For your own codegen, mirror this folder structure:

my-fixture/
├── manifest.json # the atom set
├── gen.ts # imports your template, calls runTemplate
└── expected/ # snapshot to diff against

Run bun run gen in CI; fail on diff.