Learn to describe LLM agent context structures in 20 minutes
ACDL (Agentic Context Description Language) is a notation for describing the structure of agent contexts—the system instructions, user messages, tool calls, conversation history that is sent to the llm at every turn.
when documenting prompts we care about what gets sent to the LLM at a single point in time. When describing agentic contexts we also care about how prompts are constructed and evolve across turns—which parts stay fixed, which parts accumulate, and how the structure changes dynamically. There are existing methods for describing prompts. We created ACDL to describe agentic contexts.
Ad hoc prose or prompt excerpts are imprecise, hard to verify, and make comparing implementations difficult. ACDL provides a clean, precise notation that is easy to read and compare across different systems. As your agent grows more complex, ACDL helps you see the structure at a glance, share it with others, and reason about how context evolves over time.
Let's start with the simplest possible ACDL context. Every context is a series of messages, each marked with a role (System, User, Assistant, Tool) and containing different types of content. Here's what that looks like in ACDL:
HelloWorld[@T]: { S: INSTRUCTIONS U: CONTENT }
This describes an llm context with two messages. The first message is a message who's role is System (S:) which has a single piece of content: the template INSTRUCTIONS. The second message is a message who's role is User (U:) and whose content is the template CONTENT. Like every ACDL description, the @T signifies that this is the context sent at turn T of the conversation.
Templates let you separate structure from content. INSTRUCTIONS might expand
to a long system prompt, but in ACDL we just see the structural role it plays. This keeps specifications
clean and focused on the conversation flow.
ACDL has four chat role markers: S: (System), U: (User), A: (Assistant),
and T: (Tool response). These map directly to the roles in chat-based LLM APIs.
When a role message contains multiple elements, use braces to group them together:
CodingAssistant[@T]: { S: { SYSTEM_INSTRUCTIONS CODING_GUIDELINES AVAILABLE_TOOLS } U: env.user_input[@T] }
The S: { ... } form lets you include multiple templates, variables, or any combination
of content in a single message. Without braces, a role takes just one element.
The above specification describes a coding assistant that receives 2 messages. The first message is a System message containing system instructions, coding guidelines, and a list of available tools, and the second message is the user's input at turn T. We will address the meaning of the env prefix later.
Beyond fixed templates, contexts also include dynamic content like inputs, memory contents. ACDL uses context variables that come from three sources:
env. - Environment inputs (user inputs, task descriptions, external data)sys. - Agent state (memory contents, tool configurations, action histories)resp. - LLM responses (what the model previously generated)Templates can also take arguments—most often context variables. The template's text then depends on the arguments it receives, much like a format string with parameters in Python's printf.
strings like env.user_input represent information that is tracked by the system or comes from the external world. The existence of a value like env.user_input in the context description assumes that your agent has a way to track this information.
Assistant: { S: ROLE_DESCRIPTION(env.assistant_name) U: env.user_input }
This specification shows an assistent agent that recieves 2 messages. The first message's content is a ROLE Template that is dependent on the assistant's name. The second message is the user's input at turn T. We use the env prefix here to signify that this value originated in the environment. The @T after the prompt name means "this prompt is parameterized by turn T".
All values in ACDL are immutable. A value like sys.config.role stays the same throughout
the system's lifetime. Values that change between steps are accessed with a time index:
env.user_input[@T] is the user input at the current step.
Agents have conversations that evolve over multiple turns. ACDL uses time indices to describe which turn we're talking about:
ChatAgent[@T]: { S: ROLE // you are a helpful assistant... U: env.user_input[@T] }
This specification is very similar to the previous example: it has two messages. The first is a ROLE template, this time with no arguments, and the second is a user message containing the user's input at turn T. The key difference is the time index: here the user input is parameterized by the current turn ([@T]), whereas in the earlier example it had no time index—meaning there was a single user input, constant across all turns. The [@T] after the prompt name means "this prompt is parameterized by turn T". Notice that we do not include any of the history here.
@T - The current turn (a parameter)@1, @2, etc. - Specific turn numbers@T-1 - The previous turnBelow is an example of a chat agent that also gets the user's input and the LLM's response from the 3 previous turns.
ChatAgent[@T]: { S: ROLE U: env.user_input[@T-3] A: resp.answer[@T-3] U: env.user_input[@T-2] A: resp.answer[@T-2] U: env.user_input[@T-1] A: resp.answer[@T-1] U: env.user_input[@T] }
The history can also be sent in one long message, instead of separate messages for each content piece. Here is how that would look like:
ChatAgent[@T]: { S: ROLE U: { env.user_input[@T-3] resp.answer[@T-3] env.user_input[@T-2] resp.answer[@T-2] env.user_input[@T-1] resp.answer[@T-1] env.user_input[@T] } }
Instead of writing out each of the turns, if there is a recurring pattern of what you include in the history (or in any other case), use ForEach to loop through it:
ChatWithHistory[@T]: { S: INSTRUCTIONS // Previous turns ForEach(@t: range(1, @T)) { U: env.user_input[@t] A: resp.reply[@t] } // Current turn U: env.user_input[@T] }
ForEach(@t: range(1, @T)) iterates from turn 1 up to (but not including) the current turn @T.
The loop variable @t takes each value in that range.
ForEach isn't limited to time ranges—you can loop over any list of items, such as ForEach(doc: env.documents), binding the loop variable to each element in turn.
Let's combine everything to describe a ReAct agent - an agent that reasons and uses tools in a loop:
ReactAgent[@T]: { S: { TASK_INSTRUCTIONS AVAILABLE_TOOLS } U: env.user_question // Action history ForEach(@t: range(1, @T)) { A: { resp.reasoning[@t] sys.tool_call[@t] } T: sys.tool_call[@t].response } S: CONTINUE_OR_ANSWER }
This describes the classic ReAct pattern. The first message is a System message holding two templates, TASK_INSTRUCTIONS and AVAILABLE_TOOLS. The second is a User message with the question to answer. After that come the steps of the ReAct loop—two messages per step: an Assistant message with the LLM's reasoning and the tool it chose to call, followed by a Tool message with that tool's response. Once every step up to the current one has been included, we close with a final System message that asks the LLM to either continue the loop or reply to the user.
Notice that this agent handles a single turn (the user's question) but takes multiple steps within it (the ReAct loop). We could also describe an agent that answers a series of questions, one after another, running a ReAct loop for each one. Here is an example description of such an agent:
MultiTurnReactAgent[@T]: { S: { TASK_DESCRIPTION env.tool_descriptions } // previous turns ForEach(@t: range(1, @T)) { ForEach(i: range(1, @t.substeps)) { A: sys.tool_used[@t.i] T: sys.tool_used[@t.i].tool_response } } // current turn ForEach(i: range(1, I)) { A: sys.tool_used[@T.i] T: sys.tool_used[@T.i].tool_response } }
Pay attention: the description never shows the LLM's answer for the current turn T. That's intentional—at the moment this context is assembled, turn T hasn't happened yet, so the model hasn't produced an answer. The context describes what we send to the model at turn T; the response only exists afterward.
Sometimes you need different context based on conditions. Use If, ElseIf, and
Else:
AdaptiveAgent[@T]: { S: BASE_INSTRUCTIONS If env.has_documents { S: { RAG_INSTRUCTIONS sys.retrieved_docs } } If env.has_tools { S: TOOL_INSTRUCTIONS } Else { S: NO_TOOLS_MESSAGE } U: env.user_input[@T] }
This agent adapts its instructions based on whether documents and tools are available.
Functions represent computed content—summarization, retrieval, formatting, or any transformation that cannot be expressed as a simple variable lookup. They are declared by name and purpose without defining their implementation; the name conveys semantic intent.
Markers annotate a section of a specification for visual emphasis in the rendered output. A mark draws a bracket along the side of the marked content with a number beside it (shown as ]1), and is purely presentational—it doesn't change the prompt's meaning. You can wrap anything from a single content element to a large multi-message section, and use several marks with different numbers to highlight distinct parts.
ReactToolRagAtEnd[@T]: { S: { INSTRUCTIONS } U: env.user_input[@1] // history ForEach(t: range(1, @T)) { A: { resp.tool_reasoning[@t] sys.tool_used[@t] } T: sys.tool_used[@t].tool_response } S: { Mark 1 { locate_tools(env.user_input[@1]) } USE_TOOLS_TO_SOLVE_TASK } }
This specification describes a ReAct agent that retrieves its tools at the end of the context. The first message is a System message with the agent's INSTRUCTIONS, and the second is a User message holding the original task—env.user_input[@1], fixed at the first turn. The // history loop then replays every previous step: for each turn t from 1 up to @T, an Assistant message with the model's reasoning for choosing the tool and the tool it used, followed by a Tool message with that tool's response. The final System message is where the function comes in: locate_tools(env.user_input[@1]) is computed content—rather than looking up a stored value, it runs over the original task to retrieve the relevant tools—followed by the USE_TOOLS_TO_SOLVE_TASK template. The function is declared by name and purpose only; its implementation is left out of the specification. The ]1 on the right of the function is there to highlight the function.
Beyond messages and variables, ACDL gives you tools to reuse content and to highlight parts of a specification.
String fragments are reusable pieces of content with no role of their own. You define them with the StrFrag keyword and invoke them with the Frag keyword.
StrFrag DocumentContext[doc]: { env.doc_title[doc] env.doc_content[doc] summarize(env.doc_metadata[doc]) }
This string fragment, DocumentContext, bundles together everything that describes a single document, parameterized by doc. Its body holds three content pieces: the document's title (env.doc_title[doc]), its content (env.doc_content[doc]), and a summarize(env.doc_metadata[doc]) function that condenses the document's metadata. The SF badge in the render marks it as a String Fragment—on its own it just defines a reusable block of content; it doesn't place anything in a prompt until it's invoked.
To use it, we invoke it with the Frag keyword from inside a message:
DocumentQA[@T]: { U: { TASK_INSTRUCTIONS ForEach(doc: env.documents) { Frag DocumentContext[doc] } env.user_question[@T] } }
Here, DocumentQA is an ordinary prompt with a single User message. Inside that message, a ForEach walks over env.documents and invokes Frag DocumentContext[doc] once per document. Each invocation drops the fragment's three pieces in place, and because they land inside a User message, they inherit the User role. The message ends up as TASK_INSTRUCTIONS, then a title–content–summary block for every document, then the user's question—and the document layout itself is written only once, back in the fragment definition.
Role fragments are reusable groups of whole messages. You define them with the RolesFrag keyword and invoke them at the top level of a prompt, wherever a role message would be valid. They expand to the full sequence of role messages defined in the fragment body.
Both kinds of fragment can take parameters in square brackets and are invoked with the same Frag Name[args] syntax; ACDL decides which kind is meant from context—inside a role block it resolves to a string fragment, at the top level to a role fragment.
Putting it all together, here is a tool-using agent that defines both kinds of fragment and uses each one:
StrFrag ToolDescription[tool]: { sys.tool_name[tool] sys.tool_schema[tool] } RolesFrag ToolResult[@t, tool]: { A: sys.tool_call[@t, tool] T: sys.tool_response[@t, tool] } ToolAgent[@T]: { S: { INSTRUCTIONS ForEach(tool: sys.available_tools) { Frag ToolDescription[tool] } } ForEach(@t: range(1, @T)) { U: env.observation[@t] Frag ToolResult[@t, sys.selected_tool[@t]] } U: env.observation[@T] }
ToolDescription is a string fragment and ToolResult is a role fragment, and ToolAgent uses both. Inside the System message, Frag ToolDescription[tool] is invoked within a role block, so it resolves to the string fragment—each tool's name and schema expand in place as System content. In the history loop, Frag ToolResult[@t, sys.selected_tool[@t]] sits at the top level, so it resolves to the role fragment, expanding into the Assistant and Tool messages for that step. The same Frag Name[args] syntax appears in both places; ACDL picks the right kind from where the invocation sits.