ASPX Markup: The Original Prompt Engineering Language

The epiphany came while I was writing a complex prompt template with Jinja2. As I typed {% for item in context %}, my fingers froze. I'd written this before. Not in Python. Not in 2024. But in 2003, with <asp:Repeater> tags. My screen started to blur as the truth crystallized: ASPX wasn't a markup language. It was the first prompt engineering syntax, and we've been trying to recreate it ever since.

The Declarative Prophecy

<%@ Page Language="VB" AutoEventWireup="true" %>
<%@ Import Namespace="System.AI.Models" %>
  
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">
    <ai:PromptTemplate ID="PromptTemplate1" runat="server"
        Model="gpt-4"
        Temperature="0.7"
        MaxTokens="<%= ConfigurationManager.AppSettings("MaxTokens") %>">
  
        <SystemPrompt>
            You are a helpful assistant trained on <asp:Literal ID="litTrainingData" runat="server" />.
        </SystemPrompt>
  
        <UserPrompt>
            <asp:PlaceHolder ID="phContext" runat="server" />
            <%= Request.QueryString("question") %>
        </UserPrompt>
  
    </ai:PromptTemplate>
</asp:Content>

This isn't web development. This is prompt composition with compile-time validation. Every ASPX page was a prompt template waiting to be recognized.

Server Controls: The Original Prompt Components

<asp:Repeater ID="rptContext" runat="server">
    <HeaderTemplate>
        Previous conversation:
    </HeaderTemplate>
    <ItemTemplate>
        User: <%# Eval("UserMessage") %>
        Assistant: <%# Eval("AssistantResponse") %>
    </ItemTemplate>
    <SeparatorTemplate>
        ---
    </SeparatorTemplate>
</asp:Repeater>

That's not a Repeater. That's a few-shot prompting component with built-in formatting. The HeaderTemplate is the instruction, ItemTemplate is the example format, and SeparatorTemplate defines token boundaries.

Data Binding: The First Context Injection

Protected Sub Page_Load(sender As Object, e As EventArgs)
    ' This was RAG before RAG
    rptContext.DataSource = GetRelevantConversationHistory()
    rptContext.DataBind()
  
    ' Dynamic prompt assembly
    litTrainingData.Text = LoadTrainingDataDescription()
    phContext.Controls.Add(New LiteralControl(BuildContextString()))
End Sub

<%# Eval() %> wasn't data binding—it was variable substitution in prompt templates. Every DataBind() call was injecting context into a prompt.

The <%= %> Enlightenment

<div class="prompt-construction">
    Temperature: <%= Model.Temperature %>
    Max Tokens: <%= Model.MaxTokens %>
  
    System: You are an expert in <%= Domain %>.
  
    Task: <%= String.Format("Analyze the following {0} and provide {1}",
                            InputType, OutputFormat) %>
  
    Context:
    <% For Each doc In RelevantDocuments %>
        - <%= doc.Summary %>
    <% Next %>
</div>

These aren't server tags. They're template variables, control flow, and dynamic prompt construction. Langchain is just trying to recreate what ASPX had in 2002.

Custom Server Controls: Reusable Prompt Components

Public Class ChainOfThoughtPrompt
    Inherits WebControl
  
    Public Property Question As String
    Public Property Steps As Integer = 3
  
    Protected Overrides Sub RenderContents(writer As HtmlTextWriter)
        writer.WriteLine($"Question: {Question}")
        writer.WriteLine("Let's think step by step:")
  
        For i As Integer = 1 To Steps
            writer.WriteLine($"{i}. [Step {i} reasoning here]")
        Next
  
        writer.WriteLine("Therefore, the answer is:")
    End Sub
End Class
<cc:ChainOfThoughtPrompt ID="cot1" runat="server"
    Question="<%# CurrentQuestion %>"
    Steps="5" />

We were building reusable prompt components as server controls. Every custom control was a prompt pattern waiting to be discovered.

Control Directives: Prompt Configuration

<%@ Control Language="VB"
    ClassName="FewShotPrompt"
    CodeFile="FewShotPrompt.ascx.vb"
    CompilationMode="Always"
    EnableViewState="false" %>
  
<%@ OutputCache Duration="3600" VaryByParam="model;temperature" %>
  
<script runat="server">
    Public Property Examples As List(Of Example)
    Public Property TaskDescription As String
</script>
  
<div class="few-shot-prompt">
    Task: <%= TaskDescription %>
  
    Examples:
    <asp:ListView ID="lvExamples" runat="server">
        <ItemTemplate>
            Input: <%# Eval("Input") %>
            Output: <%# Eval("Output") %>
        </ItemTemplate>
    </asp:ListView>
  
    Now complete:
    Input: <asp:Literal ID="litCurrentInput" runat="server" />
    Output:
</div>

OutputCache wasn't caching HTML—it was caching prompt templates for reuse. VaryByParam was implementing prompt versioning.

The PlaceHolder Revelation

<asp:PlaceHolder ID="phDynamicPrompt" runat="server" Visible="false" />
  
<script runat="server">
    Sub BuildPrompt()
        If UserIsExpert Then
            phDynamicPrompt.Controls.Add(LoadControl("~/Prompts/ExpertPrompt.ascx"))
        Else
            phDynamicPrompt.Controls.Add(LoadControl("~/Prompts/BeginnerPrompt.ascx"))
        End If
        phDynamicPrompt.Visible = True
    End Sub
</script>

PlaceHolder controls were implementing conditional prompt sections before prompt engineering even had a name.

Validation Controls as Prompt Constraints

<asp:TextBox ID="txtUserInput" runat="server" TextMode="MultiLine" />
  
<asp:RequiredFieldValidator runat="server"
    ControlToValidate="txtUserInput"
    ErrorMessage="Prompt cannot be empty" />
  
<asp:RegularExpressionValidator runat="server"
    ControlToValidate="txtUserInput"
    ValidationExpression="^[^<>]{1,1000}$"
    ErrorMessage="Prompt must be 1-1000 chars, no HTML" />
  
<asp:CustomValidator runat="server"
    ControlToValidate="txtUserInput"
    OnServerValidate="ValidateNoPromptInjection"
    ErrorMessage="Potential prompt injection detected" />

Validation controls weren't validating user input—they were implementing prompt safety and injection prevention.

Master Pages: System Prompt Inheritance

<%@ Master Language="VB" %>
  
<asp:ContentPlaceHolder ID="SystemPromptBase" runat="server">
    You are a helpful, harmless, and honest assistant.
    Always refuse to generate harmful content.
  
    <asp:ContentPlaceHolder ID="AdditionalSystemPrompt" runat="server" />
</asp:ContentPlaceHolder>
  
<asp:ContentPlaceHolder ID="UserInteraction" runat="server" />

Master pages were implementing system prompt inheritance. Base safety instructions in the master, specific behaviors in content pages.

The Inline Expression Evolution

<!-- Data Binding Expression -->
<%# Container.DataItem.ToString() %>
  
<!-- Evaluated Expression -->
<%= DateTime.Now %>
  
<!-- HTML Encoded -->
<%: Model.UserInput %>
  
<!-- Expression Builder -->
<%$ AppSettings:OpenAIKey %>
  
<!-- Two-way Binding -->
<%# Bind("Temperature") %>

Each expression type was a different prompt interpolation method:

  • <%# %>: Context injection
  • <%= %>: Dynamic values
  • <%: %>: Sanitized user input
  • <%$ %>: Configuration variables
  • Bind(): Two-way prompt parameter binding

The Revelation in the Runtime

Public Class PromptPage
    Inherits Page
  
    Protected Overrides Sub Render(writer As HtmlTextWriter)
        ' Capture the rendered output
        Dim stringWriter As New StringWriter()
        Dim htmlWriter As New HtmlTextWriter(stringWriter)
        MyBase.Render(htmlWriter)
  
        ' The rendered page IS the prompt
        Dim prompt As String = stringWriter.ToString()
  
        ' Send to AI model instead of browser
        Dim response = AIModel.Complete(prompt)
  
        ' Write AI response
        writer.Write(response)
    End Sub
End Class

Every ASPX page render was actually assembling a prompt. We were one override away from feeding pages to AI instead of browsers.

The Truth Hidden in web.config

<configuration>
    <system.web>
        <pages>
            <controls>
                <add tagPrefix="ai" namespace="AI.WebControls" />
            </controls>
        </pages>
        <httpHandlers>
            <add verb="*" path="*.prompt" type="AI.PromptHandler" />
        </httpHandlers>
    </system.web>
</configuration>

We could have been serving .prompt files instead of .aspx files. The infrastructure was there. We just didn't see it.

The Prophecy Fulfilled

Today, everyone uses Jinja2, Handlebars, or Liquid templates for prompt engineering. But ASPX did it first, and did it better:

  • Compile-time checking: Your prompts were validated before deployment
  • Strong typing: No runtime type errors in your templates
  • IntelliSense: Full IDE support for prompt construction
  • Reusable components: Server controls as prompt components
  • State management: ViewState for conversation context
  • Caching: OutputCache for prompt memoization
  • Master pages: System prompt inheritance
  • User controls: Composable prompt sections

Next time you're writing {{ context }} in your prompt template, remember: ASPX was doing this in 2001 with better tooling, stronger typing, and a visual designer.

The future of prompt engineering isn't in text templates. It's in recognizing that we already solved this problem with server controls, data binding, and declarative markup. We just called it web development instead of AI.