Skip to content
p promptel. github ↗
← all notes
2025-11-02 dsltutorialanatomy

Reading a promptel file: anatomy of a declarative prompt

Walk through a real .prompt file block by block. What each piece means, why it exists, and what the AST looks like when the parser is done with it.

The fastest way to understand a DSL is to read a file in it and ask, of every block, “what is this for?” We will do that with a real promptel file — the math-solver example from the repo — and finish with a clear picture of what the parser produces and how the executor walks it.

Here is the file in full.

prompt MathProblemSolver {
  meta {
    name: "Math Problem Solver";
    version: "1.0";
    description: "A prompt for solving mathematical problems step-by-step.";
  }

  context {
    role: "You are an expert mathematician.";
    background: "You have extensive knowledge in algebra, calculus, and problem-solving strategies.";
  }

  params {
    problem: string;
    difficulty: string = "medium";
    steps?: number;
  }

  body {
    text`Let's solve the following ${params.difficulty} mathematical problem:

    ${params.problem}

    We'll break this down step-by-step to arrive at the solution.`;

    technique {
      chainOfThought {
        step("Understand the Problem") {
          text`First, let's clearly state what we're trying to solve and identify the key information provided.`;
        }
        step("Devise a Plan") {
          text`Now, let's outline our approach to solving this problem. What mathematical concepts or formulas will we need to apply?`;
        }
        step("Execute the Plan") {
          text`Let's carry out our plan, showing each mathematical step clearly.`;
        }
        step("Review and Verify") {
          text`Finally, let's check our solution. Does it make sense? Can we verify it in a different way?`;
        }
      }
    }
  }

  constraints {
    maxTokens: 800;
    temperature: 0.3;
  }

  output {
    format: "json";
    schema: {
      solution: string,
      steps: string[],
      verification: string
    }
  }
}

That is a complete prompt. It compiles, it runs, and it gives back a typed result. Let us read it from the top.

The prompt keyword and the name

A promptel file begins with prompt followed by an identifier. The identifier is not cosmetic — it is the name the AST gets stamped with, the value the CLI reports, and the key your eval harness uses to group runs. Treat it like a function name. Make it unique in your repo.

The opening brace starts the body. Everything between the braces is a block. There are six top-level block types: meta, context, params, body, constraints, and output. (There is also hooks, which we will skip here because it is the one block that intentionally embeds host-language code.) The parser cares about order only loosely; it cares about content strictly.

meta — for the humans and the CI

The meta block is a small dictionary of strings. The names are conventional: name, version, description. The version field is the one that matters most. A prompt is a thing you ship to production. It should be versioned. When CI sees a diff in the body and no diff in the version string, it should fail. This is the cheapest, most useful policy you can put in place around prompts, and the DSL makes it possible because there is a named field to check.

context — the system role

The context block carries the parts of the prompt that, on most providers, end up in the system slot. It is structured because system roles are not just “the system prompt is one string.” Real production system messages have two or three threads: who the model is, what background it has, what constraints it must always respect. Splitting those out in the DSL makes them reviewable separately.

In the AST, context becomes a node with fields. In the lowering step, the provider abstraction concatenates the fields into the system message the provider expects.

params — typed inputs with defaults

This is where the DSL stops being a templating engine and starts being a small language. Each parameter has a name, a type, an optionality marker, and possibly a default value. The grammar accepts string, number, boolean, plus optional markers with ? and defaults with =.

The executor enforces the contract. If you call executePrompt(file, {}) against a prompt whose problem parameter has no default and is not marked optional, the executor refuses before any token leaves your machine. This is the difference between catching the bug in your test suite and catching it in your provider bill.

In the AST, params becomes a list of { name, type, optional, default } records. The body’s template literals reference these by ${params.name}, and the parser checks at parse time that every reference resolves.

body — the prose, plus technique

The body block is where most of the actual prompt lives. It contains one or more text blocks, which are tagged template literals. The text inside a text block is the same template syntax you already use in JavaScript — interpolations are ${expression}, and the parser handles them as expression nodes, not as opaque strings. That is why ${params.difficulty} is a real lookup the parser can validate.

Nested inside the body is the optional technique block. This is the place where a named prompting technique enters the AST as a structured node. In the example, the technique is chainOfThought, with four step("name") { ... } children, each containing one or more text blocks. The executor knows how to render a Chain-of-Thought block into the wire format for each provider — including, on providers that expose reasoning channels, lifting the steps into the appropriate channel rather than the user-visible text.

Other techniques follow the same shape. fewShot has example { input, output } children. treeOfThoughts has branches and an aggregator. selfConsistency declares a sample count. The shape is consistent enough that a reviewer can skim the body block and know, without reading the prose, which technique is in play.

constraints — the provider knobs

maxTokens, temperature, topP, stop. These are the knobs every provider has, in roughly the same form. The DSL gives them a dedicated block so that the prompt — not the calling code — owns its inference parameters. A prompt that requires temperature 0.2 should carry that requirement with it, not depend on the caller to remember.

In the AST, constraints becomes a small record of typed fields. The provider abstraction lowers each field into the right slot for the SDK in use.

output — what shape you expect back

The output block declares the format you expect from the model. The simplest form is format: "text". The interesting form is format: "json" with a schema. When a provider supports structured output natively, the executor wires the schema into the SDK call so the model returns valid JSON shaped to the schema. When the provider does not, the executor falls back to a JSON-mode prompt suffix and validates the result on the way out.

The output block is the one that pays the largest dividend in production. Validated JSON is a thing the rest of your application can use; “probably JSON, sometimes a prose explanation, sometimes wrapped in code fences” is not.

What the AST looks like

After parsing, the file becomes a single node with the prompt’s name and six (or seven, with hooks) child nodes, each typed. The body is a tree, not a flat string. The technique is a child node, not a substring of the body. The params are a list of typed records, not a dictionary of Any.

You can call parsePrompt(source) and pretty-print the AST. Doing so on this file produces a structure that fits on one screen and answers every question you would want to ask about the prompt programmatically: which params does it want, what type are they, which technique is applied, what constraints does the prompt enforce, what shape does the output take.

Why this matters

A prompt as a string is opaque. A prompt as an AST is a thing your tooling can introspect. Every promptel file is both — the file on disk is readable by a human, the parsed form is readable by a machine, and the executor walks the AST identically regardless of whether it was authored in the DSL or in YAML.

That is the entire thesis of the format. The file you just read is short, scannable, and reviewable. The thing it compiles into is structured enough to be testable, evaluatable, and provider-portable. Both halves matter. Promptel exists because the gap between them is wide enough to be worth a small language.