Getting Started with ACDL

Learn to describe LLM agent context structures in 20 minutes

1

What is ACDL?

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.

2

Your First ACDL Description

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
}
Rendered

HelloWorld[@T]:

System
INSTRUCTIONS
User
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.

The Roles

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]
}
Rendered

CodingAssistant[@T]:

System
SYSTEM_INSTRUCTIONS
CODING_GUIDELINES
AVAILABLE_TOOLS
User
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.

3

Adding Variables

Beyond fixed templates, contexts also include dynamic content like inputs, memory contents. ACDL uses context variables that come from three sources:

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
}
Rendered

Assistant:

System
ROLE_DESCRIPTION(env.assistant_name)
User
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".

Immutable Values

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.

5

Time Indices: Tracking Conversation Turns

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]
}
Rendered

ChatAgent[@T]:

System
ROLE // you are a helpful assistant...
User
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.

Below 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]
}
Rendered

ChatAgent[@T]:

System
ROLE
User
env.user_input[@T-3]
Assistant
resp.answer[@T-3]
User
env.user_input[@T-2]
Assistant
resp.answer[@T-2]
User
env.user_input[@T-1]
Assistant
resp.answer[@T-1]
User
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]
    }
}
Rendered

ChatAgent[@T]:

System
ROLE
User
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]
6

Loops: Including Conversation History

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]
}
Rendered

ChatWithHistory[@T]:

System
INSTRUCTIONS
// Previous turns
ForEach @t : 1 ... @T
User
env.user_input[@t]
Assistant
resp.reply[@t]
// Current turn
User
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.

7

Putting It Together: A ReAct Agent

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
}
Rendered

ReactAgent[@T]:

System
TASK_INSTRUCTIONS
AVAILABLE_TOOLS
User
env.user_question
// Action history
ForEach @t : 1 ... @T
Assistant
resp.reasoning[@t]
sys.tool_call[@t]
Tool
sys.tool_call[@t].response
System
CONTINUE_OR_ANSWER
See this in the Live Editor

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
    }
}
Rendered

MultiTurnReactAgent[@T]:

System
TASK_DESCRIPTION
env.tool_descriptions
// previous turns
ForEach @t : 1 ... @T
ForEach i : 1 ... @t.substeps
Assistant
sys.tool_used[@t.i]
Tool
sys.tool_used[@t.i].tool_response
// current turn
ForEach i : 1 ... I
Assistant
sys.tool_used[@T.i]
Tool
sys.tool_used[@T.i].tool_response
No answer at turn T

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.

8

Conditionals: Dynamic Context

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]
}
Rendered

AdaptiveAgent[@T]:

System
BASE_INSTRUCTIONS
If env.has_documents
System
RAG_INSTRUCTIONS
sys.retrieved_docs
If env.has_tools
System
TOOL_INSTRUCTIONS
Else
System
NO_TOOLS_MESSAGE
User
env.user_input[@T]

This agent adapts its instructions based on whether documents and tools are available.

9

Functions and Markers

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
    }
}
Rendered

ReactToolRagAtEnd[@T]:

System
INSTRUCTIONS
User
env.user_input[@1]
// history
ForEach t : 1 ... @T
Assistant
resp.tool_reasoning[@t]
sys.tool_used[@t]
Tool
sys.tool_used[@t].tool_response
System
locate_tools(env.user_input[@1])
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.

10

Fragments

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])
}
Rendered

DocumentContext[doc]

SF
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]
    }
}
Rendered

DocumentQA[@T]:

User
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]
}
Rendered

ToolDescription[tool]

SF
sys.tool_name[tool]
sys.tool_schema[tool]

ToolResult[@t, tool]

RF
Assistant
sys.tool_call[@t, tool]
Tool
sys.tool_response[@t, tool]

ToolAgent[@T]:

System
INSTRUCTIONS
ForEach tool : sys.available_tools
Frag ToolDescription[tool]
ForEach @t : 1 ... @T
User
env.observation[@t]
Frag ToolResult[@t, sys.selected_tool[@t]]
User
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.

What's Next?