3. Prompts and Structured Output
Goal: write prompt templates, attach output parsers that turn replies into structured data, watch the agent repair a malformed reply on its own, get Pydantic-typed output, and reuse prompts through inheritance and a registry.
In LLLM a Prompt is more than a string. It is the complete behavior contract
for one agent turn:
- Template: the text sent to the model, with
{variable}placeholders. - Output contract: a
parser(or a structured-outputformat) that turns the reply into data. - Tools: functions and MCP servers offered during the turn, on top of the agent's own tools (Lesson 4).
- Handlers: the messages used to recover from errors and to feed tool results back.
This is what makes LLLM agents reliable enough for program synthesis and other research pipelines. When a reply breaks the output contract, the agent sends the error back to the model and asks again, automatically, and only the corrected reply is kept in the dialog.
Where Pydantic fits: Prompt(format=MyModel) asks the provider for JSON that
matches a Pydantic model, and a parser can validate replies with Pydantic so
that validation errors drive the same repair loop. Prompts and parsers are
Pydantic models themselves.
Prerequisites
Finish Lesson 2. The examples reuse its agent;
any agent works. Everything except the respond() calls runs locally, so you
can try prompts and parsers without an API key.
Step 1: Create a prompt
from lllm import Prompt
greet = Prompt(
path="greet/system",
prompt="You are a friendly assistant. Greet the user warmly.",
)
print(greet()) # render the template
path is the prompt's identifier. It shows up in logs and is the key under
which the prompt is registered (Step 11), so keep it unique.
Step 2: Use template variables
Placeholders use Python str.format() syntax. Calling the prompt renders it:
analyst = Prompt(
path="analyst/system",
prompt="You are an expert in {domain}. Today's date is {date}.",
)
print(analyst(domain="machine learning", date="2026-03-17"))
# You are an expert in machine learning. Today's date is 2026-03-17.
print(sorted(analyst.template_vars)) # ['date', 'domain']
print(sorted(analyst.validate_args({"domain": "ml"}))) # ['date']: still missing
Rendering with a missing variable raises a ValueError that names the missing
variables, so a forgotten argument fails loudly instead of reaching the model.
Literal braces are always written doubled, whether or not the template has variables:
json_hint = Prompt(
path="json/system",
prompt='Reply with JSON like {{"answer": "..."}} about {topic}.',
)
print(json_hint(topic="cats")) # Reply with JSON like {"answer": "..."} about cats.
plain = Prompt(path="json/plain", prompt='Reply with JSON like {{"answer": "..."}}.')
print(plain()) # Reply with JSON like {"answer": "..."}.
Step 3: Use prompts in a dialog
There are two places a prompt enters a dialog:
agent.system_prompt = analyst
agent.open("session", prompt_args={"domain": "finance", "date": "2026-03-17"})
print(agent.current_dialog.head.content)
# You are an expert in finance. Today's date is 2026-03-17.
agent.open(alias, prompt_args=...)renders the agent's system prompt with those arguments.agent.receive_prompt(prompt, prompt_args)renders any prompt and appends it as the next user message. You will use it in the next step.
This distinction matters because of one rule worth remembering:
The most recent prompt put into a dialog drives the next
respond(). Its parser,format, and handlers decide how the reply is checked and repaired, and any tools it declares are offered on top of the agent's own tools.receive(text)puts a plain prompt with no parser, so the reply comes back as free text.
So when a turn needs structured output, send it with receive_prompt().
Step 4: Parse XML tags
Attach a DefaultTagParser to the prompt for the turn:
from lllm import Prompt
from lllm.runtimes.native import DefaultTagParser
extract = Prompt(
path="extractor/task",
prompt="""Extract the people and organizations mentioned in the text.
Answer in exactly this format:
<people>comma-separated names</people>
<organizations>comma-separated organization names</organizations>
Text: {text}""",
parser=DefaultTagParser(xml_tags=["people", "organizations"]),
)
You can test the output contract without a model by parsing a sample reply:
from pprint import pprint
pprint(
extract.parse("<people>Alice, Bob</people><organizations>Acme Corp</organizations>"),
sort_dicts=False,
)
{'raw': '<people>Alice, Bob</people><organizations>Acme Corp</organizations>',
'xml_tags': {'people': ['Alice, Bob'], 'organizations': ['Acme Corp']},
'md_tags': {},
'signal_tags': {}}
Each tag maps to a list because a tag can appear more than once. Now run it on a real turn:
agent.receive_prompt(extract, {"text": "Alice and Bob met the Acme Corp board in Paris."})
message = agent.respond()
print(message.parsed["xml_tags"]["people"])
message.parsed holds the same dictionary shape, built from the model's reply.
With this native parser, every tag listed in xml_tags (and in
required_xml_tags) must appear in the reply, otherwise parsing fails and the
repair loop in Step 7 kicks in. If a tag is optional, use the portable parser,
which only enforces the required_* lists:
from lllm.parsers import DefaultTagParser as PortableTagParser
lenient = PortableTagParser(xml_tags=["organizations"], required_xml_tags=["people"])
print(lenient.parse("<people>Alice</people>")["xml_tags"])
# {'organizations': [], 'people': ['Alice']}
Native prompts accept either parser. Note that from lllm import
DefaultTagParser imports this portable parser; the native one lives in
lllm.runtimes.native.
Step 5: Parse fenced code blocks
md_tags extracts fenced Markdown blocks by their language tag:
coder = Prompt(
path="coder/task",
prompt="Write a Python function that {goal}. Put the code in a ```python block.",
parser=DefaultTagParser(md_tags=["python"]),
)
parsed = coder.parse("Here it is:\n```python\ndef add(a, b):\n return a + b\n```")
print(parsed["md_tags"]["python"]) # ['def add(a, b):\n return a + b']
Step 6: Detect signal tags
Signal tags let the model raise a flag. They are reported as booleans and never fail parsing:
review = Prompt(
path="classifier/task",
prompt="Is this review positive? If it is, include <POSITIVE> in your reply.\n\nReview: {review}",
parser=DefaultTagParser(signal_tags=["POSITIVE"]),
)
print(review.parse("Clearly happy with the product. <POSITIVE>")["signal_tags"]) # {'POSITIVE': True}
print(review.parse("Mixed feelings.")["signal_tags"]) # {'POSITIVE': False}
Step 7: Watch the repair loop
When the parser raises, the agent does not fail right away. It shows the model
its own reply plus an error message and asks again, up to the agent's
max_exception_retry (default 3):
strict = Prompt(
path="math/answer",
prompt="Compute {expression}. Put only the final number in <answer> tags.",
parser=DefaultTagParser(xml_tags=["answer"]),
)
agent.receive_prompt(strict, {"expression": "17 * 23"})
session = agent.respond(return_session=True)
print(session.delivery.parsed["xml_tags"]["answer"])
print(session.exception_retries_count) # how many repairs were needed
If a reply has no <answer> tag, the next request the model sees ends with its
bad reply followed by this message from the prompt's default handler:
Error: Parsing errors:
No answer tags found, it should be provided as <answer>...</answer>. Please fix.
Repairs happen on a working copy of the dialog, so your dialog keeps only the
accepted reply. When retries run out, respond() raises AgentException with
the last parsing error. You can reword the repair message per prompt with
handler=DefaultSimpleHandler(exception_msg="...");
Lesson 8 shows custom handlers.
Step 8: Write a custom parser
Subclass DefaultTagParser and override parse() to add your own validation.
Raise ParseError with a message the model can act on:
from lllm.runtimes.native import DefaultTagParser, ParseError
def has_cycle(edges):
graph = {}
for src, dst in edges:
graph.setdefault(src, []).append(dst)
visiting, done = set(), set()
def visit(node):
if node in done:
return False
if node in visiting:
return True
visiting.add(node)
if any(visit(nxt) for nxt in graph.get(node, [])):
return True
visiting.discard(node)
done.add(node)
return False
return any(visit(node) for node in list(graph))
class DagParser(DefaultTagParser):
"""Parse 'a -> b' lines inside <edges> and reject cyclic graphs."""
def parse(self, content: str, **kwargs) -> dict:
parsed = super().parse(content, **kwargs)
edges = [
tuple(part.strip() for part in line.split("->"))
for line in parsed["xml_tags"]["edges"][0].strip().splitlines()
]
if has_cycle(edges):
raise ParseError("The graph contains a cycle. Return an acyclic graph.")
parsed["edges"] = edges
return parsed
dag = DagParser(xml_tags=["edges"])
print(dag.parse("<edges>\nload -> clean\nclean -> train\n</edges>")["edges"])
# [('load', 'clean'), ('clean', 'train')]
Attach it with Prompt(..., parser=DagParser(xml_tags=["edges"])). A cyclic
reply now goes back to the model with "The graph contains a cycle" until it
returns a valid graph. The native ParseError is a subclass of
lllm.ParseError, so except lllm.ParseError catches failures from either
parser.
A parser can also be a plain function parser(content, **kwargs) -> dict.
Whatever it returns is stored in message.parsed: a dict is kept as is
(non-dict values are wrapped as {"value": ...}), and "raw" is set to the
reply text unless the dict already has that key. Any exception it raises
triggers the repair loop. Extra keyword arguments come from
agent.respond(parser_args={...}).
Step 9: Get Pydantic-typed output
There are two ways to get typed output.
Validate with Pydantic inside a parser
This works with any model. Validation errors become repair messages, so the model is told exactly which field is missing or malformed:
from pydantic import BaseModel
from lllm import Prompt, find_md_blocks
class Summary(BaseModel):
headline: str
key_points: list[str]
sentiment: str
def summary_parser(content: str, **kwargs) -> dict:
blocks = find_md_blocks(content, "json") # accept ```json fences too
text = blocks[0] if blocks else content
return Summary.model_validate_json(text).model_dump()
summarize = Prompt(
path="summarizer/task",
prompt=(
"Summarize the article as a JSON object with the keys "
"headline, key_points (a list of strings), and sentiment.\n\n{article}"
),
parser=summary_parser,
)
article = "LLLM adds parsers and repair loops so agent outputs stay structured."
agent.receive_prompt(summarize, {"article": article})
summary = Summary.model_validate(agent.respond().parsed)
print(summary.headline, summary.key_points)
Ask the provider for structured output
format= passes the model class to the provider's structured-output mode, so
the model is constrained to emit matching JSON:
structured = Prompt(
path="summarizer/structured",
prompt="Summarize the following article:\n\n{article}",
format=Summary,
)
agent.receive_prompt(structured, {"article": article})
message = agent.respond()
summary = Summary.model_validate(message.parsed) # parsed holds the decoded JSON dict
Things to know about format:
- It needs a model and provider that support structured output, and the default
completion API (
api_type="completion"). message.parsedis the decoded JSON as a dict; callSummary.model_validate()to get the model instance.- The prompt's
parseris not run whenformatis set. Invalid JSON still triggers the repair loop.
Step 10: Extend a prompt
extend() copies every field (template, parser, tools, handler, format) and
applies your overrides. A new path is required:
base = Prompt(
path="base/system",
prompt="You are a helpful assistant working on {topic}.",
)
python_expert = base.extend(
path="python_expert/system",
prompt="You are a senior Python engineer working on {topic}. Always include type hints.",
)
print(python_expert(topic="a CLI tool"))
Calling extend() without path raises ValueError, because two prompts with
the same path would collide in the registry.
Step 11: Register prompts and load them by path
Prompts can live in a registry and be fetched by path anywhere in your code:
from lllm import Prompt, load_prompt, register_prompt
register_prompt(Prompt(
path="greeter/system",
prompt="You are a warm, friendly assistant. The user's name is {name}.",
))
greeter = load_prompt("greeter/system")
print(greeter(name="Ada"))
register_prompt() adds the prompt to the default runtime registry. Dialogs
accept registered paths directly, so agent.receive_prompt("extractor/task",
{...}) works once extractor/task is registered.
In a project you rarely register prompts by hand: an LLLM package declares its
prompt folders in lllm.toml (a prompts/ folder is picked up by default),
and every Prompt defined in those files is registered when the package loads.
lllm create sets this up for you (Lesson 1), and
Lesson 6 covers packages.
Summary
| Feature | How |
|---|---|
| Template variables | {variable} in the template; literal braces always as {{ }} |
| Render manually | prompt(var=value) |
| Check variables | prompt.template_vars, prompt.validate_args(args) |
| Send a structured turn | agent.receive_prompt(prompt, prompt_args) |
| XML tags | DefaultTagParser(xml_tags=[...]) from lllm.runtimes.native |
| Markdown blocks | DefaultTagParser(md_tags=[...]) |
| Signal flags | DefaultTagParser(signal_tags=[...]) |
| Optional tags | lllm.parsers.DefaultTagParser(xml_tags=[...], required_xml_tags=[...]) |
| Custom validation | Subclass DefaultTagParser or pass a function; raise ParseError |
| Repair budget | Agent(max_exception_retry=N) |
| Pydantic output | Validate in a parser, or Prompt(format=MyModel) + MyModel.model_validate(message.parsed) |
| Inheritance | prompt.extend(path=..., ...) |
| Registry | register_prompt(prompt), load_prompt(path) |
See the Prompts reference for every field.