Loading Langfuse prompts in .NET with labels and version pins
If your .NET service fetches prompts from Langfuse, put the fetch, the variable compilation and the shape check behind one loader, and drive it from one label plus a map of version pins keyed by prompt name. Each new prompt is then a name constant rather than three new settings, and a malformed prompt fails with a clear error before any model is called.
What goes wrong with a per-prompt settings triple?
The first prompt we moved into Langfuse was simple to wire up. The service read a prompt name, an optional label and an optional version from configuration, fetched the prompt, compiled its variables and pulled out the system message, the user message and a JSON schema. All of that lived inside the one service that used it.
It stops scaling at the second prompt. Every prompt needs its own name, label and version settings, and every service repeats the same fetch, compile and validation code with slightly different mistakes. The environment story gets muddy too: you want "production" prompts in most places, but the odd environment needs to freeze on a specific version while someone investigates a regression.
How do labels and version pins fit together?
Langfuse lets you fetch a prompt either by label (for example production) or by explicit version number. A label is a moving pointer: editing the prompt in the Langfuse UI and moving the label changes behaviour without a deploy. A version is immutable, so it is the right tool when you need a known-good prompt.
The settings shape that worked for us is one label for the whole service and a dictionary of pins:
public sealed class PromptSettings
{
// Applied to every prompt unless it is pinned.
public string? Label { get; init; }
// Prompt name -> version. A pin wins over the label.
public Dictionary<string, int> VersionPins { get; init; } = new();
}
The rule is a single line: if the name has a pin, ask for that version and send no label; otherwise ask for the label. Sending both is ambiguous, so the loader never does.
What should the loader return?
Return a small, already-validated record rather than the raw Langfuse prompt. Callers then cannot forget a check:
public sealed record CompiledPrompt(
string Name,
int Version,
string SystemPrompt,
string UserPrompt,
string ToolName,
JsonElement Schema);
public interface IPromptLoader
{
Task<Result<CompiledPrompt>> LoadAsync(
string name,
IReadOnlyDictionary<string, string> variables,
CancellationToken ct = default);
}
The implementation does three things in order. It resolves label or version from the settings. It fetches the prompt and compiles the double-brace variable placeholders. Then it checks the prompt carries what a structured-output call needs: a system message, a user message, and a response schema in the prompt's config. If any of that is missing it returns an error that names the prompt and its version.
That last check matters more than it looks. A prompt is edited by people in a UI, not compiled by a build. Somebody removing the schema from the config is a perfectly reasonable thing to do by accident, and without the check you find out as an obscure failure deep inside the model call.
Why keep the service's own logic separate?
After the extraction, the service that classifies things keeps only what is specific to it: building the variables, restricting the answer to an allowed set of values taken from the schema, and filtering the result. It no longer knows how Langfuse addresses prompts, which makes it easy to test with a fake IPromptLoader and keeps the loader's own tests focused on pins, labels, schema checks and failure paths.
Does a slash in a prompt name work as a folder?
Langfuse supports organising prompts into folders with slashes in the name. With the community zborek.LangfuseDotnet client at version 0.15.0, the name is percent-escaped when it goes into the URL path, so a/b is requested as a%2Fb. We did not rely on that reaching Langfuse as a folder path. Instead we kept prompt names flat, and added two tests: one pinning the escaped request URL, and one asserting that no registered name contains a slash.
The registry itself is a static class of string constants plus an All list. A test uses reflection to confirm All matches the declared constants, so a prompt added to the class cannot be silently left out of any check that walks the list:
public static class LangfusePrompts
{
public const string Classification = "classification-prompt";
public static IReadOnlyList<string> All { get; } = new[] { Classification };
}
How should environments choose a label?
Every environment currently points at the same production label, so the per-environment setting changes nothing today. We still set it explicitly in each environment's infrastructure variables. The lever is then visible where people look when they ask "which prompt is staging using?", and moving one environment to a different label later is a one-line change rather than a code change.
Key takeaways
- Use one label per service and a dictionary of version pins keyed by prompt name, so adding a prompt never adds settings.
- Make a pin win over the label and send only one of them, so the request is never ambiguous.
- Validate the prompt's messages and response schema in the loader, because prompts are edited outside your build and can drift.
- Keep prompt names flat and test it, since the client escapes a slash in the URL path.
- Set the label explicitly per environment even when the values match, so the lever is visible.