Declarative agents let you define an entire LangGraph agent in a single YAML file – the graph topology, prompts, LLMs, tools, skills, and routing logic. Dynamic behavior is expressed with CEL (Common Expression Language) expressions evaluated against the agent’s state at runtime.
A declarative agent YAML has these top-level sections:
manifest_version: 1 # Required: schema version (currently 1)
prompts: # Prompt definitions
llms: # LLM declarations
tools: # Tool imports
skills: # Skill card references
sub_agents: # Sub-agent declarations (for multi-agent orchestration)
hooks: # Hook executor imports
checkpointer: # Checkpointer configuration (for state persistence)
default_llm: # Default LLM for call_llm nodes (optional)
agents: # Agent graph definitions
Every declarative agent YAML must include a manifest_version field as a top-level integer. This tracks the version of the declarative agent schema that the YAML uses, allowing a single DeclarativeAgent runtime to handle agent.yaml files with varying manifest versions simultaneously.
The current manifest version is 1. Increment it when breaking changes are made to the schema.
manifest_version: 1
All entity registrations and the graph definition live in one file, giving you a complete snapshot of the agent.
prompts:
- id: my-prompt
version: "1.0.0"
instructions: >
You are a helpful assistant.
Be concise and accurate.
Prompts are accessible in CEL expressions via prompts["my-prompt"]["instructions"].
llms:
- id: openai-gpt-4o-mini
version: "1.0.0"
provider: openai # Currently supports "openai"
model_name: gpt-4o-mini
The LLM provider reads API keys from environment variables (e.g., OPENAI_API_KEY).
Tools reference Python callables by import path:
tools:
- id: get_weather
version: "1.0.0"
import_path: my_package.tools.get_weather
The import path should point to a LangChain/LangGraph @tool-decorated function.
skills:
- id: weather
version: "1.0.0"
skill_card_path: ../skills/weather/skill-card.json # Relative to YAML file
# url: https://example.com/skill-card.json # Or remote URL
See Skills for details on skill cards and progressive disclosure.
hooks:
- import_path: my_package.hooks.LoggingHook
- import_path: my_package.hooks.GuardrailHook
Hooks can also be passed programmatically via the DeclarativeAgent constructor. See Hooks.
Declare other agents as sub-agents to enable multi-agent orchestration. Sub-agents are automatically wrapped as LangGraph tools that the supervisor LLM can invoke. See Multi-Agent for the full guide.
sub_agents:
- id: weather-agent
version: "1.0.0"
yaml_path: weather-agent.yaml # Relative to this YAML file
- id: search-agent
version: "1.0.0"
import_path: my_agents.search_agent # From a Python module
- id: remote-agent
version: "1.0.0"
url: https://remote-agent.example.com # Remote A2A agent
- id: pre-registered-agent
version: "1.0.0"
# No source -- expects the agent to already be in the registry
The checkpointer enables state persistence across graph invocations, which is required for features like interrupt nodes (human-in-the-loop). By default, DeclarativeAgent uses an in-memory checkpointer (MemorySaver), so you don’t need to configure anything for basic usage.
To explicitly declare a checkpointer in YAML:
checkpointer:
type: memory # In-memory checkpointer (currently the only supported type)
You can also pass a checkpointer programmatically via the constructor:
from langgraph.checkpoint.memory import MemorySaver
agent = DeclarativeAgent(
id="my-agent",
version="1.0.0",
yaml_path="agent.yaml",
checkpointer=MemorySaver(),
)
When a checkpointer is active, all graph invocations require a thread_id in the config to identify the conversation thread. The send_message method handles this automatically using context_id, task_id, or a generated UUID.
When multiple call_llm nodes use the same LLM, you can set a top-level default_llm instead of repeating the llm field on every node:
manifest_version: 1
default_llm:
id: openai-gpt-4o-mini
llms:
- id: openai-gpt-4o-mini
version: "1.0.0"
provider: openai
model_name: gpt-4o-mini
agents:
my-agent:
state:
fields:
- name: messages
type: list
default: []
graph:
entry_point: agent
nodes:
- name: agent
type: call_llm
args:
# No llm field -- inherits from default_llm
prompt:
- role: system
content: '"You are helpful."'
- role: messages
content: 'messages'
edges: []
A step-level llm always takes precedence over default_llm. If neither is set, graph construction raises an error.
Each agent is defined under the agents key:
agents:
my-agent:
state:
fields:
- name: messages
type: list
default: []
- name: counter
type: int
default: 0
graph:
entry_point: first_node
nodes:
- name: first_node
type: call_llm
args: ...
edges:
- source: first_node
target: __end__
The state section defines the agent’s state shape. Supported types: str, int, float, bool, list, dict.
If a field named messages is present (type list), sherma uses LangGraph’s MessagesState as the base class, which provides the standard message accumulation behavior.
State fields are available in all CEL expressions.
call_llmCalls an LLM with a prompt and optional tool bindings. The llm field can be omitted when a top-level default_llm is configured (see Default LLM).
- name: agent
type: call_llm
args:
llm: # Optional when default_llm is set
id: openai-gpt-4o-mini
version: "1.0.0"
prompt:
- role: system
content: 'prompts["my-prompt"]["instructions"]'
- role: messages
content: 'messages'
tools: # Optional: bind specific tools
- id: get_weather
version: "1.0.0"
# use_tools_from_registry: true # Or: bind ALL registered tools
# use_tools_from_loaded_skills: true # Or: bind tools from loaded skills
# use_sub_agents_as_tools: true # Or: bind all sub-agents as tools
# use_sub_agents_as_tools: # Or: bind specific sub-agents
# - id: weather-agent
# version: "1.0.0"
The prompt field is an array of message items. Each item has a role and a content (a CEL expression):
| Role | Behavior |
|---|---|
system |
CEL evaluates to a string, wrapped as a SystemMessage |
human |
CEL evaluates to a string, wrapped as a HumanMessage |
ai |
CEL evaluates to a string, wrapped as an AIMessage |
messages |
CEL evaluates to a list of messages, spliced in place preserving their original roles |
The messages role is how you inject conversation history into the prompt. State messages are never auto-injected – you must explicitly include them with role: messages. This gives you full control over where conversation history appears relative to system instructions and other messages.
# Typical pattern: system prompt, then conversation history
prompt:
- role: system
content: 'prompts["my-prompt"]["instructions"]'
- role: messages
content: 'messages'
# Advanced: inject history in the middle, add a trailing instruction
prompt:
- role: system
content: 'prompts["sys"]["instructions"]'
- role: messages
content: 'messages'
- role: human
content: '"Now summarize the above conversation"'
# Few-shot examples via explicit roles
prompt:
- role: system
content: '"You classify sentiment as positive or negative."'
- role: human
content: '"I love this product!"'
- role: ai
content: '"positive"'
- role: messages
content: 'messages'
Tool binding modes:
| Mode | Description |
|---|---|
tools (explicit list) |
Bind the listed tools (can be combined with any flag below) |
use_tools_from_registry: true |
Bind all tools in the registry |
use_tools_from_loaded_skills: true |
Bind only tools loaded via skill discovery |
use_sub_agents_as_tools: true / all |
Bind all sub-agents declared in sub_agents as tools |
use_sub_agents_as_tools: [list] |
Bind specific sub-agents by id/version |
use_sub_agents_as_tools accepts three forms: true (or all) to bind all declared sub-agents, a list of RegistryRef objects (id + version) to bind a specific subset, or false (default) to disable. Each ref’s id must match a declared sub_agents entry.
The dynamic flags (use_tools_from_registry, use_tools_from_loaded_skills, use_sub_agents_as_tools) are mutually exclusive with each other. However, an explicit tools list can be combined with any single dynamic flag – the tools are merged additively and deduplicated by name.
Auto-injected tool_node: When a call_llm node has tools, sherma automatically injects a tool_node after it with the correct conditional edges. If the LLM responds with tool calls, execution routes to the tool node; otherwise it continues to the next edge. You don’t need to wire this manually.
tool_nodeExecutes tool calls from the last AIMessage. Usually auto-injected, but can be declared explicitly:
- name: tools
type: tool_node
args:
tools: # Optional: restrict to specific tools
- id: get_weather
version: "1.0.0"
If no tools list is provided, the node resolves all tools from the registry.
call_agentInvokes another registered agent:
- name: delegate
type: call_agent
args:
agent:
id: sub-agent
version: "1.0.0"
input: 'messages[size(messages) - 1]' # CEL expression for input
The agent can be local or remote. The input is evaluated as a CEL expression against state, sent as an A2A message, and the response is added to messages.
data_transformTransforms state using a CEL expression that returns a dict:
- name: update_stats
type: data_transform
args:
expression: '{"query_count": query_count + 1, "status": "done"}'
The returned dict is merged into the state. Only include the keys you want to update.
set_stateSets individual state variables via CEL expressions:
- name: init
type: set_state
args:
values:
counter: "0"
status: '"ready"' # Note: string literals need inner quotes
Each value is a CEL expression. String literals must be double-quoted inside the YAML string.
interruptPauses graph execution to request human input:
- name: ask_user
type: interrupt
args: {}
The interrupt value sent to the A2A client is always the last AIMessage from the graph state — not the CEL value expression. This is a hard contract: every interrupt node must be preceded by a call_llm node that produces an AIMessage. If no AIMessage exists in state when the interrupt fires, a RuntimeError is raised.
This design ensures the user always sees the agent’s actual reply (e.g., a question, a summary, a status update) rather than a raw interrupt string.
When the user responds, execution resumes from this node. The user’s response is wrapped as a HumanMessage and appended to state.
The same contract applies to tools that call interrupt() directly (e.g., a request_user_input tool). The interrupt value must be an AIMessage:
from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langgraph.types import interrupt
@tool
def request_user_input(question: str) -> str:
"""Ask the user for more information."""
response = interrupt(AIMessage(content=question))
return str(response)
This ensures send_message can always convert interrupt values to A2A messages without inspecting the graph’s message history.
edges:
- source: node_a
target: node_b
Use __end__ as the target to terminate the graph.
Use CEL expressions for dynamic routing:
edges:
- source: reflect
branches:
- condition: 'messages[size(messages) - 1].contains("TASK_COMPLETE")'
target: __end__
- condition: 'retry_count < 3'
target: retry
default: summarize # Fallback if no branch matches
Branches are evaluated in order. The first matching condition determines the target. If no branch matches and no default is set, the graph ends.
CEL is used throughout the YAML for dynamic behavior. Expressions have access to:
messages, counter, etc.)prompts["prompt-id"]["instructions"]llms["llm-id"]["model_name"]CEL supports standard operations: arithmetic, string manipulation, list operations (size(), indexing), map construction, comparisons, and boolean logic.
CEL can also handle Pydantic models, dataclasses, and any object with __dict__ – these are automatically converted to CEL maps, so you can access their fields with standard map syntax (e.g., obj.field or obj["field"]).
# Access last message content
'messages[size(messages) - 1]'
# Build a dict for state transformation
'{"count": count + 1, "status": "done"}'
# Conditional check
'messages[size(messages) - 1].contains("COMPLETE")'
# Reference a registered prompt
'prompts["my-prompt"]["instructions"]'
# String literal (note inner quotes)
'"hello world"'
# Integer literal
'42'
from sherma import DeclarativeAgent
agent = DeclarativeAgent(
id="my-agent", # Must match an agent key in the YAML
version="1.0.0",
yaml_path="agent.yaml",
)
agent = DeclarativeAgent(
id="my-agent",
version="1.0.0",
yaml_content=yaml_string,
base_path=Path("path/to/yaml/dir"), # Required for relative file paths
)
When using yaml_content, relative file paths in the YAML (like skill_card_path or sub-agent yaml_path) cannot be resolved without a base_path. If your YAML references only absolute paths or Python import paths, base_path is not needed.
from sherma import DeclarativeConfig, load_declarative_config
config = load_declarative_config(yaml_path="agent.yaml")
agent = DeclarativeAgent(
id="my-agent",
version="1.0.0",
config=config,
base_path=Path("path/to/yaml/dir"), # Required for relative file paths
)
from my_hooks import LoggingHook, GuardrailHook
agent = DeclarativeAgent(
id="my-agent",
version="1.0.0",
yaml_path="agent.yaml",
hooks=[LoggingHook(), GuardrailHook()],
)
All file paths in a YAML config (skill_card_path, sub-agent yaml_path) are resolved against a base_path:
yaml_path provided: base_path is automatically derived from the YAML file’s parent directory. No manual setup needed.yaml_content or config provided: Set base_path explicitly if the YAML contains relative file paths.base_path.base_path: Raise a DeclarativeConfigError.This ensures agents work correctly from any working directory, not just the project root.
# These paths are resolved relative to the YAML file's directory:
skills:
- id: weather
version: "1.0.0"
skill_card_path: ../skills/weather/skill-card.json # Relative to YAML dir
sub_agents:
- id: weather-agent
version: "1.0.0"
yaml_path: weather_agent.yaml # Relative to YAML dir
What is NOT affected by base_path:
import_path (tools, agents, hooks) – uses Python’s importlib and sys.pathbase_uri – resolved relative to the skill card file’s own locationA skill-aware agent that discovers skills, executes tasks, and reflects on results. Note the use of default_llm to avoid repeating the LLM reference on every node:
manifest_version: 1
prompts:
- id: discover-skills
version: "1.0.0"
instructions: >
You have access to a catalog of skills. Given the user's request:
1. Call list_skills to see available skills.
2. Call load_skill_md for the most relevant skill.
3. Respond with a brief text summary.
- id: plan-and-execute
version: "1.0.0"
instructions: >
Based on the loaded skills, plan and execute the user's request.
- id: reflect
version: "1.0.0"
instructions: >
Review the results. If complete, respond with "TASK_COMPLETE"
followed by the answer. Otherwise respond "NEEDS_MORE_WORK".
llms:
- id: openai-gpt-4o-mini
version: "1.0.0"
provider: openai
model_name: gpt-4o-mini
default_llm:
id: openai-gpt-4o-mini
skills:
- id: weather
version: "1.0.0"
skill_card_path: skills/weather/skill-card.json
agents:
skill-agent:
state:
fields:
- name: messages
type: list
default: []
graph:
entry_point: discover_skills
nodes:
- name: discover_skills
type: call_llm
args:
prompt:
- role: system
content: 'prompts["discover-skills"]["instructions"]'
- role: messages
content: 'messages'
tools:
- id: list_skills
- id: load_skill_md
- name: execute
type: call_llm
args:
prompt:
- role: system
content: 'prompts["plan-and-execute"]["instructions"]'
- role: messages
content: 'messages'
use_tools_from_loaded_skills: true
- name: reflect
type: call_llm
args:
prompt:
- role: system
content: 'prompts["reflect"]["instructions"]'
- role: messages
content: 'messages'
edges:
- source: discover_skills
target: execute
- source: execute
target: reflect
- source: reflect
branches:
- condition: 'messages[size(messages) - 1].contains("TASK_COMPLETE")'
target: __end__
default: execute