The AI-Native IDP -- Part 3
AI-Powered Software Templates
The Problem
In article 1 we created a .NET API template. It generates a project with PostgreSQL, JWT auth, and Kubernetes manifests. Every time. For every service.
But not every service needs PostgreSQL. Not every service needs JWT. Some need Redis. Some need Service Bus. Some need all of them. The template doesn’t know.
The usual fix is to create more templates. A template for “API with PostgreSQL.” Another for “API with Redis.” Another for “Worker with Service Bus.” Another for “API with PostgreSQL and Redis and Service Bus.” The number of templates grows with every combination. The platform team spends time maintaining templates instead of building the platform.
And even with the right template, the generated project is generic. The README says “Your service description here.” The configuration has placeholder values. The developer still needs to go through every file and customize it for their specific use case.
There’s a bigger missed opportunity: the moment a developer creates a new service is the perfect moment to set them up with AI-assisted development. If the project comes with a pre-configured GOTCHA prompt — already filled with the right Goals, Tools, and Context for this specific service — the developer can start working with AI immediately, not after spending 30 minutes writing a prompt from scratch.
The Solution
Replace the static template with an AI-powered scaffolder. The developer describes what they need in plain English:
“A .NET API that manages invoices. It connects to PostgreSQL for storage and publishes events to Azure Service Bus when an invoice is created or paid. It needs JWT auth via QuantumID. Deployed to Kubernetes.”
The scaffolder:
- Parses the description with the AI model
- Determines the right dependencies, configuration, and infrastructure
- Generates the project files — not from a fixed skeleton, but tailored to the request
- Creates a GOTCHA prompt pre-filled with the service’s specific context
- Registers the new service in the catalog
One template. Many variations. And every project starts with AI-ready documentation.
Execute
The AI Service Endpoint
We extend the .NET AI service from article 2 with a new endpoint:
app.MapPost("/api/scaffold", async (ScaffoldRequest request, IConfiguration config) =>
{
if (string.IsNullOrWhiteSpace(request.Description))
return Results.BadRequest(new { error = "Description is required." });
var endpoint = config["AI:Endpoint"];
var apiKey = config["AI:Key"];
var model = config["AI:ChatModel"] ?? "mistral-small-3.2-24b-instruct-2506";
var provider = config["AI:Provider"] ?? "openai";
ChatClient chatClient = provider.ToLowerInvariant() switch
{
"azure" => new AzureOpenAIClient(
new Uri(endpoint!), new ApiKeyCredential(apiKey!))
.GetChatClient(model),
_ => new OpenAIClient(
new ApiKeyCredential(apiKey!),
new OpenAIClientOptions { Endpoint = new Uri(endpoint!) })
.GetChatClient(model),
};
var systemPrompt = """
You are a project scaffolder for a .NET cloud platform.
Given a service description, produce a JSON object with:
- "name": kebab-case service name (e.g. "invoice-api")
- "description": one-line description (max 120 chars)
- "type": "api" | "worker" | "function"
- "dependencies": object with boolean flags:
{ "postgresql": bool, "redis": bool, "serviceBus": bool, "blobStorage": bool }
- "auth": "quantumid-jwt" | "api-key" | "none"
- "kubernetes": true | false
- "nugetPackages": array of NuGet package names needed
- "envVars": array of environment variable names the service will need
- "gotchaPrompt": a complete GOTCHA prompt with all 6 layers filled in
for this specific service. Use the GOTCHA format:
GOALS, ORCHESTRATION, TOOLS, CONTEXT, HEURISTICS, ARGS.
Be specific to this service — not generic.
Respond ONLY with valid JSON, no markdown.
""";
try
{
var completion = await chatClient.CompleteChatAsync(
[
new SystemChatMessage(systemPrompt),
new UserChatMessage(request.Description),
]);
var raw = completion.Value.Content[0].Text.Trim();
var json = raw.StartsWith("```") ? raw.Split('\n', 2)[1].TrimEnd('`').Trim() : raw;
var scaffold = JsonSerializer.Deserialize<ScaffoldResult>(json, SerializerOptions.Default);
if (scaffold is null)
return Results.UnprocessableEntity(new { error = "AI returned invalid scaffold spec." });
// Normalize gotchaPrompt: the AI may return it as an object or a string
var gotchaStr = scaffold.GotchaPrompt.ValueKind == JsonValueKind.String
? scaffold.GotchaPrompt.GetString() ?? ""
: scaffold.GotchaPrompt.ToString();
return Results.Ok(new
{
scaffold.Name, scaffold.Description, scaffold.Type,
scaffold.Dependencies, scaffold.Auth, scaffold.Kubernetes,
scaffold.NugetPackages, scaffold.EnvVars,
gotchaPrompt = gotchaStr,
});
}
catch (ClientResultException ex) when (ex.Status == 401)
{
return Results.Json(new { error = "AI provider authentication failed." }, statusCode: 503);
}
catch (Exception ex)
{
return Results.Json(new { error = $"AI provider error: {ex.Message}" }, statusCode: 502);
}
});
record ScaffoldRequest(string Description);
record ScaffoldResult(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("dependencies")] Dictionary<string, bool> Dependencies,
[property: JsonPropertyName("auth")] string Auth,
[property: JsonPropertyName("kubernetes")] bool Kubernetes,
[property: JsonPropertyName("nugetPackages")] List<string> NugetPackages,
[property: JsonPropertyName("envVars")] List<string> EnvVars,
[property: JsonPropertyName("gotchaPrompt")] JsonElement GotchaPrompt);
This uses the same AI provider pattern from article 2 — same IConfiguration, same error handling. The gotchaPrompt field uses JsonElement because some models return it as a string and others as a structured object. We normalize it to a string before sending it to the scaffolder.
What the AI Returns
For the invoice API description, the AI service returns something like:
{
"name": "invoice-api",
"description": "REST API for invoice management with event-driven notifications via Service Bus",
"type": "api",
"dependencies": {
"postgresql": true,
"redis": false,
"serviceBus": true,
"blobStorage": false
},
"auth": "quantumid-jwt",
"kubernetes": true,
"nugetPackages": [
"Npgsql.EntityFrameworkCore.PostgreSQL",
"Azure.Messaging.ServiceBus",
"Microsoft.AspNetCore.Authentication.JwtBearer",
"Serilog.AspNetCore",
"FluentValidation.AspNetCore"
],
"envVars": [
"DB_CONNECTION",
"SERVICEBUS_CONNECTION",
"QUANTUMID_AUTHORITY",
"QUANTUMID_AUDIENCE"
],
"gotchaPrompt": "=== GOALS ===\nBuild a .NET 10 REST API for invoice management...[full prompt]"
}
The GOTCHA prompt is the most valuable part. It’s not a generic template — it’s specific to this service, with the right tools, the right environment variables, and heuristics that match the dependencies.
The Backstage Scaffolder Action
Backstage’s scaffolder supports custom actions. We create one that calls the AI service and generates the project:
// plugins/ai-scaffolder/src/actions/aiScaffold.ts
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import * as fs from 'fs-extra';
import * as path from 'path';
export function createAiScaffoldAction(aiServiceUrl: string) {
return createTemplateAction({
id: 'forge:ai-scaffold',
schema: {
input: {
description: z => z.string().describe('Service Description'),
owner: z => z.string().describe('Owner'),
},
output: {
name: z => z.string().optional(),
gotchaPrompt: z => z.string().optional(),
},
},
async handler(ctx) {
const { description, owner } = ctx.input;
ctx.logger.info(`Generating scaffold for: ${description}`);
const res = await fetch(`${aiServiceUrl}/api/scaffold`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description }),
});
if (!res.ok) {
throw new Error(`AI scaffold service returned ${res.status}`);
}
const scaffold = await res.json();
const projectDir = ctx.workspacePath;
// Generate Program.cs
await fs.writeFile(
path.join(projectDir, 'Program.cs'),
generateProgramCs(scaffold),
);
// Generate .csproj with the right NuGet packages
await fs.writeFile(
path.join(projectDir, `${scaffold.name}.csproj`),
generateCsproj(scaffold),
);
// Generate appsettings.json with env var placeholders
await fs.writeFile(
path.join(projectDir, 'appsettings.json'),
generateAppSettings(scaffold),
);
// Generate Dockerfile
await fs.writeFile(
path.join(projectDir, 'Dockerfile'),
generateDockerfile(scaffold),
);
// Generate catalog-info.yaml
await fs.writeFile(
path.join(projectDir, 'catalog-info.yaml'),
generateCatalogInfo(scaffold, owner),
);
// Generate the GOTCHA prompt
await fs.writeFile(
path.join(projectDir, 'GOTCHA.md'),
formatGotchaPrompt(scaffold),
);
// Generate K8s manifests if needed
if (scaffold.kubernetes) {
await fs.ensureDir(path.join(projectDir, 'k8s'));
await fs.writeFile(
path.join(projectDir, 'k8s', 'deployment.yaml'),
generateK8sDeployment(scaffold),
);
await fs.writeFile(
path.join(projectDir, 'k8s', 'service.yaml'),
generateK8sService(scaffold),
);
}
ctx.output('name', scaffold.name);
ctx.output('gotchaPrompt', scaffold.gotchaPrompt);
ctx.logger.info(`Scaffold complete: ${scaffold.name}`);
},
});
}
Backstage 1.37 uses Zod for schema validation in scaffolder actions. The z parameter is injected by the framework — you don’t import it. If you’re on an older Backstage version, the schema format uses JSON Schema objects instead.
The File Generators
Each generator produces a real, runnable file — not a placeholder:
// plugins/ai-scaffolder/src/generators/programCs.ts
interface ScaffoldResult {
name: string;
dependencies: Record<string, boolean>;
auth: string;
envVars: string[];
}
export function generateProgramCs(scaffold: ScaffoldResult): string {
const lines: string[] = [];
// Usings based on actual dependencies
if (scaffold.dependencies.postgresql) {
lines.push('using Microsoft.EntityFrameworkCore;');
}
if (scaffold.dependencies.serviceBus) {
lines.push('using Azure.Messaging.ServiceBus;');
}
lines.push('using Serilog;');
lines.push('');
lines.push('var builder = WebApplication.CreateBuilder(args);');
lines.push('');
lines.push('builder.Host.UseSerilog((ctx, config) =>');
lines.push(' config.ReadFrom.Configuration(ctx.Configuration));');
lines.push('');
// Database
if (scaffold.dependencies.postgresql) {
lines.push('builder.Services.AddDbContext<AppDbContext>(options =>');
lines.push(' options.UseNpgsql(');
lines.push(' builder.Configuration.GetConnectionString("Default")));');
lines.push('');
}
// Service Bus
if (scaffold.dependencies.serviceBus) {
lines.push('builder.Services.AddSingleton(_ =>');
lines.push(' new ServiceBusClient(');
lines.push(' Environment.GetEnvironmentVariable("SERVICEBUS_CONNECTION")));');
lines.push('');
}
// Auth
if (scaffold.auth === 'quantumid-jwt') {
lines.push('builder.Services');
lines.push(' .AddAuthentication("Bearer")');
lines.push(' .AddJwtBearer(options =>');
lines.push(' {');
lines.push(' options.Authority = Environment.GetEnvironmentVariable("QUANTUMID_AUTHORITY");');
lines.push(' options.Audience = Environment.GetEnvironmentVariable("QUANTUMID_AUDIENCE");');
lines.push(' });');
lines.push('builder.Services.AddAuthorization();');
lines.push('');
}
lines.push('var app = builder.Build();');
lines.push('');
if (scaffold.auth === 'quantumid-jwt') {
lines.push('app.UseAuthentication();');
lines.push('app.UseAuthorization();');
lines.push('');
}
lines.push('app.MapGet("/healthz", () => Results.Ok("healthy"));');
lines.push('');
lines.push('app.Run();');
return lines.join('\n');
}
The GOTCHA.md File
This is what makes this scaffolder different from every other template. Every generated project includes a GOTCHA.md file:
# GOTCHA Prompt — invoice-api
Use this prompt with your AI tool to develop this service.
Generated by Forge based on your service description.
## GOALS
Build a .NET 10 REST API for invoice management.
CRUD operations for invoices (create, read, update, delete).
Publish InvoiceCreated and InvoicePaid events to Azure Service Bus.
JWT authentication via QuantumID. RFC 7807 error responses.
Handle 200 concurrent requests.
## ORCHESTRATION
1. Entity models (Invoice, InvoiceItem)
2. EF Core DbContext + migrations
3. Repository layer
4. Service Bus publisher
5. Endpoint handlers (Minimal API)
6. Validators (FluentValidation)
7. Auth middleware
8. Health check endpoint
## TOOLS
- .NET 10, ASP.NET Minimal API
- EF Core 10 + Npgsql
- Azure.Messaging.ServiceBus
- FluentValidation
- Serilog
- QuantumID (OIDC JWT)
## CONTEXT
- Part of the victorz-cloud platform
- Deploys to Kubernetes (Scaleway Kapsule)
- PostgreSQL database (connection via DB_CONNECTION env var)
- Service Bus (connection via SERVICEBUS_CONNECTION env var)
- Auth: QuantumID JWT (QUANTUMID_AUTHORITY, QUANTUMID_AUDIENCE)
- All logs structured via Serilog
## HEURISTICS
DO:
- Async everywhere — no .Result or .Wait()
- Repository pattern — controllers don't touch DbContext
- Return DTOs, not entities
- Publish events after successful DB transaction only
- Soft delete via DeletedAt nullable column
DON'T:
- No secrets in code — all from environment variables
- No catching base Exception — catch specific types
- No synchronous Service Bus sends in request pipeline
- Don't expose internal IDs in events — use correlation IDs
## ARGS
DB_CONNECTION: env var
SERVICEBUS_CONNECTION: env var
QUANTUMID_AUTHORITY: https://auth.quantumapi.eu
QUANTUMID_AUDIENCE: invoice-api
Port: 8080
K8s replicas: 2
Health check: /healthz
The developer opens this file, copies it into Claude or any AI tool, and starts building. The AI already knows the stack, the environment, the rules, and the concrete values. No 30 minutes writing a prompt — it’s ready from minute zero.
The Template YAML
The Backstage template that uses the custom action:
# templates/ai-service/template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: ai-service
title: AI-Powered Service (describe what you need)
description: Describe your service in plain English. AI generates the project with the right dependencies and a GOTCHA prompt.
tags:
- ai
- recommended
spec:
owner: team-platform
type: service
parameters:
- title: What do you need?
required:
- description
- owner
properties:
description:
title: Describe your service
type: string
ui:widget: textarea
ui:options:
rows: 4
ui:placeholder: "A .NET API that manages invoices. It connects to PostgreSQL and publishes events to Service Bus when an invoice is created or paid. JWT auth via QuantumID. Deployed to Kubernetes."
owner:
title: Owner
type: string
ui:field: OwnerPicker
steps:
- id: ai-scaffold
name: Generate Project
action: forge:ai-scaffold
input:
description: ${{ parameters.description }}
owner: ${{ parameters.owner }}
- id: publish
name: Publish to GitHub
action: publish:github
input:
allowedHosts: ["github.com"]
repoUrl: github.com?owner=victorZKov&repo=${{ steps['ai-scaffold'].output.name }}
description: ${{ parameters.description }}
defaultBranch: main
- id: register
name: Register in Catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
output:
links:
- title: Repository
url: ${{ steps.publish.output.remoteUrl }}
- title: Open in Catalog
icon: catalog
entityRef: ${{ steps.register.output.entityRef }}
Your GOTCHA prompt is ready in GOTCHA.md — open it and paste it into your AI tool to start building.
The UI shows a textarea. The developer writes what they need. Clicks “Create.” Gets a repo with everything configured and a GOTCHA prompt ready.
Registering the Custom Action
Custom scaffolder actions in the new Backstage backend system are registered as modules on the scaffolder plugin:
// packages/backend/src/modules/aiScaffoldModule.ts
import { createBackendModule } from '@backstage/backend-plugin-api';
import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node';
import { coreServices } from '@backstage/backend-plugin-api';
import { createAiScaffoldAction } from '@internal/plugin-ai-scaffolder';
export const aiScaffoldModule = createBackendModule({
pluginId: 'scaffolder',
moduleId: 'ai-scaffold',
register(reg) {
reg.registerInit({
deps: {
scaffolder: scaffolderActionsExtensionPoint,
config: coreServices.rootConfig,
},
async init({ scaffolder, config }) {
const aiServiceUrl = config.getString('forge.aiServiceUrl');
scaffolder.addActions(createAiScaffoldAction(aiServiceUrl));
},
});
},
});
Then in packages/backend/src/index.ts:
import { aiScaffoldModule } from './modules/aiScaffoldModule';
backend.add(aiScaffoldModule);
And add the config in app-config.yaml:
forge:
aiServiceUrl: http://localhost:5100
If you use Node.js 20 or newer, start Backstage with NODE_OPTIONS=--no-node-snapshot — the scaffolder requires it.
Checklist
- AI scaffold endpoint (
/api/scaffold) returns valid project specification - Custom scaffolder action generates all project files
- Generated
Program.cscompiles and runs with the specified dependencies - GOTCHA.md is specific to the described service (not generic)
- Template YAML registered in Backstage and visible in the UI
- Generated project includes
catalog-info.yamlwith correct metadata - Kubernetes manifests generated when
kubernetes: true
Before the Next Article
You can now create a service by describing what you need. The project comes with the right dependencies and a GOTCHA prompt ready for AI-assisted development.
But what about the code you write after the project is scaffolded? Every PR needs review. Most code review happens after the fact — the reviewer sees the diff, but doesn’t know if the code follows the team’s architectural patterns or if it fits the service’s role in the system.
What if the reviewer had access to the service catalog? What if it knew this PR is for the invoice-api, that it should use the repository pattern, and that it should never call Service Bus synchronously?
That’s article 4: The AI Code Review Plugin.
If this series helps you, consider buying me a coffee.
This is article 3 of the AI-Native IDP series. Next: The AI Code Review Plugin — PR reviews with architectural context from the catalog.
Loading comments...