A modular framework for designing and orchestrating complex agentic workflows with ease.
Project description
Modular Agent Designer
A modular framework for designing and orchestrating complex agentic workflows with ease.
Define entire workflows — graph topology, nodes, edges, agents, tools, and model configuration — with ease. No Python changes required to add a new agent, tool, model, or workflow edge.
Install
Requires uv and Python 3.13+.
uv sync --prerelease=allow # install runtime
uv sync --extra telemetry --prerelease=allow # install with MLflow tracing support
uv pip install -e ".[dev]" --prerelease=allow # add pytest + dev tools
Verify:
uv run python -c "from google.adk import Agent, Workflow; from google.adk.models.lite_llm import LiteLlm; print('OK')"
Quickstart
Scaffold a new agent (recommended for first-time use)
# Create a new agent project folder pre-wired to a local Ollama model:
uv run modular-agent-designer create my_agent
# Start Ollama and pull the default model (first time only):
ollama serve &
ollama pull gemma:e4b
# Run via CLI:
uv run modular-agent-designer run my_agent/my_agent.yaml --input '{"message": "hello"}'
# Or launch the interactive ADK web UI:
adk web my_agent
The create command generates my_agent/ containing:
my_agent.yaml— a minimal single-agent Ollama workflow you can editagent.py— entry point foradk web(exposesroot_agent)__init__.py— makes the folder a Python package (required byadk web)README.md— per-agent quickstart
modular-agent-designer create <agent-name> [--dir <parent>] [--force]
Run an existing example
# Ollama must be running with at least one model pulled:
ollama serve &
ollama pull gemma:e4b
uv run modular-agent-designer run workflows/hello_world.yaml --input '{"topic":"tide pools"}'
Output: a JSON object containing each agent's output under its YAML name key.
YAML Schema
A workflow file is a single YAML document with five top-level sections:
name: my_workflow
description: Optional description.
models:
<alias>:
provider: ollama | anthropic | google | openai # required
model: <provider_prefix>/<model_name> # required; see Model Providers table
temperature: 0.7 # optional
max_tokens: 1024 # optional
tools:
# Bundled native tool — resolved by short name from the framework's built-in registry.
# See "Bundled Tools" below for available names.
<alias>:
type: builtin
name: fetch_url # short name; no import path needed
# Alternatively, reference any bundled tool by its full dotted path:
<alias>:
type: builtin
ref: modular_agent_designer.tools.fetch_url
# Arbitrary Python callable — imported by dotted ref. Points to any function
# importable in the current environment: your own packages, third-party libs, etc.
<alias>:
type: python
ref: dotted.module.path.to_callable
# MCP server — stdio subprocess (e.g. npx, python3)
<alias>:
type: mcp_stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
env: {} # optional; ${VAR} placeholders are expanded from env
tool_filter: [read_file] # optional; restrict which MCP tools are exposed
tool_name_prefix: fs # optional; prefix to avoid name collisions
# MCP server — SSE transport
<alias>:
type: mcp_sse
url: http://localhost:8080/sse
headers: {} # optional; ${VAR} placeholders expanded from env
# MCP server — Streamable HTTP transport
<alias>:
type: mcp_http
url: https://api.example.com/mcp/
headers:
Authorization: "Bearer ${MY_TOKEN}" # ${VAR} expanded at load time; fails fast if unset
agents:
<agent_name>:
model: <model_alias> # reference to models section
description: "What this agent does" # optional; used by parent LLM to choose sub-agents
instruction: | # inline prompt (use for short, simple prompts)
Template text with {{state.key}} or {{state.nested.key}} refs.
# -- OR --
instruction_file: prompts.my_agent # dotted ref → <cwd>/prompts/my_agent.md
static_instruction: | # optional; never changes — eligible for prompt caching
You are a helpful assistant.
# -- OR --
static_instruction_file: prompts.my_agent_system # same dotted-ref format
tools: [tool_alias, ...] # optional; references to tools section
input_schema: pkg.module.MyModel # optional; Pydantic BaseModel for agent-as-tool input
output_schema: pkg.Module.Class # optional; Pydantic v2 class for structured output
output_key: custom_result # optional; state key to write output (default: agent name)
generate_content_config: # optional; per-agent generation overrides
temperature: 0.2 # 0.0–2.0
top_p: 0.9 # 0.0–1.0
top_k: 40
max_output_tokens: 1024
candidate_count: 1
stop_sequences: ["---"]
seed: 42
presence_penalty: 0.0
frequency_penalty: 0.0
response_mime_type: "application/json" # or "text/plain"
safety_settings:
- category: HARM_CATEGORY_HARASSMENT
threshold: BLOCK_NONE
thinking: # optional; enables BuiltInPlanner (Gemini only)
thinking_budget: 2048 # tokens: 0=disabled, -1=auto, >0=explicit budget
include_thoughts: false # include reasoning tokens in response
retry: # optional; retry on error
max_retries: 3 # 1–10, default 3
backoff: fixed # fixed | exponential
delay_seconds: 1.0 # seconds between retries
<custom_node_name>:
type: node # escape hatch for non-LLM logic
ref: pkg.module.MyBaseNode # importable BaseNode subclass or @node function
workflow:
nodes: [agent_a, agent_b, agent_c] # all nodes that participate
edges:
- from: agent_a
to: agent_b
condition: "success" # optional: only follow if agent_a yields "success"
- from: agent_a
to: agent_c
condition: default # optional: fallback if no other condition matches
- from: agent_a
to: [agent_b, agent_c]
parallel: true # optional: fan-out to multiple nodes concurrently
join: agent_d # optional: barrier node — wait for all targets
- from: agent_a
to: agent_b
condition: "retry"
loop: # optional: intentional cycle with a safety limit
max_iterations: 3
on_exhausted: agent_c # optional: route here when limit reached
- from: agent_a
to: error_handler
on_error: true # optional: fire only if agent_a fails all retries
- from: agent_a
to: timeout_handler
on_error: true
error_type: TimeoutError # optional: match a specific exception class name
- from: agent_a
to: fallback_handler
on_error: true
error_match: "rate.?limit" # optional: regex match on error message
condition: default # optional: default fallback among error edges
- from: router_agent
to: "{{state.router_agent}}" # dynamic destination — resolved from state at runtime
allowed_targets: [node_a, node_b] # optional: restrict candidate nodes
- from: classifier
switch: "{{state.classifier}}" # switch/case sugar — expands to N eval-condition edges
cases:
urgent: handle_urgent
normal: handle_normal
default: handle_other
entry: agent_a # first node to run
Branching & Loops
The Modular Agent Designer supports complex graph topologies including conditional branching, retry loops, parallel fan-out, and error routing directly in YAML.
Conditional Edges
Nodes (especially Agents) can yield a value that determines which edge to follow.
edges:
- from: classifier
to: tech_support
condition: "tech" # Exact match
- from: classifier
to: billing_support
condition: ["billing", "invoice"] # List-based OR logic
- from: classifier
to: human_handoff
condition: default # Catch-all fallback
Python expression conditions (eval)
For conditions that can't be expressed as exact strings or lists, use an eval block to write a Python expression:
edges:
- from: classifier
to: vip_handler
condition:
eval: "state.get('user', {}).get('is_vip') == True"
- from: classifier
to: large_request_handler
condition:
eval: "len(state.get('items', [])) > 10"
- from: classifier
to: urgent_handler
condition:
eval: "bool(re.search(r'urgent|asap', input, re.IGNORECASE))"
- from: classifier
to: fallback
condition: default
Variables available inside eval:
| Name | Value |
|---|---|
state |
Full session state dict (ctx.state.to_dict()) |
input |
Upstream node's output, coerced to a stripped string |
raw_input |
Upstream node's raw output value (dict, list, etc.) |
re |
Python re module (regex) |
Safe builtins available: len, int, float, str, bool, abs, min, max, any, all, isinstance, sorted, sum, range, list, dict, set, tuple, enumerate, zip, reversed, round.
Error handling: if the expression raises KeyError, AttributeError, IndexError, or TypeError (e.g. a missing state key), it is treated as False and a WARNING is logged. NameError and SyntaxError propagate immediately so broken expressions fail loudly at run time.
Validation rules
The loader enforces these rules at parse time (before any workflow runs):
- A source node may not have more than one
defaultedge. - A source node may not mix unconditional edges (no
condition) with conditional edges — choose one type per source. - Edges that form a cycle must have a
loop:config — accidental cycles withoutloop:are rejected at load time with a clear error message.
Switch/Case Sugar
For branching on a single state value, the switch: form is more concise than writing N separate condition: {eval: ...} edges:
edges:
- from: classifier
switch: "{{state.classifier}}" # state template: {{state.x.y.z}}
cases:
urgent: handle_urgent
normal: handle_normal
low: handle_low
default: handle_other # optional catch-all
This expands at load time into standard condition: {eval: ...} edges — the builder sees plain edges. The switch: value can be a {{state.x.y}} template or an {eval: "expr"} block:
- from: scorer
switch:
eval: "state.get('scorer', {}).get('score', 0) > 0.8"
cases:
"True": high_quality
"False": low_quality
See workflows/switch_example.yaml for a runnable example.
Dynamic Destination
When a router agent's output determines the next node by name, use a template in to: instead of writing one exact-match edge per candidate:
agents:
router:
model: llm
instruction: |
Pick a specialist: analyst, writer, or researcher.
Reply with exactly one word.
workflow:
nodes: [router, analyst, writer, researcher]
entry: router
edges:
- from: router
to: "{{state.router}}" # resolved from state at runtime
allowed_targets: [analyst, writer, researcher] # documents + constrains candidates
to:accepts any{{state.x.y}}template string. Node-set validation is skipped at load time; the resolved name is validated at runtime.allowed_targetsis optional — when omitted, all workflow nodes are candidates. When provided, only those nodes are wired as route targets and unknown names fail validation at load time.- If the template resolves to a name that isn't among the candidates, the workflow terminates with a logged error.
See workflows/dynamic_router.yaml for a runnable example.
Loop Config (Controlled Cycles)
For review/revision loops where a node routes back to a prior node, use loop: to set a safety limit and an optional escape route:
edges:
# reviewer → writer loop (max 3 revisions)
- from: reviewer
to: writer
condition: "revise"
loop:
max_iterations: 3 # 1–100; required
on_exhausted: finalizer # optional: route here when limit reached
# reviewer → finalizer (when approved)
- from: reviewer
to: finalizer
condition: "approved"
| Field | Type | Default | Description |
|---|---|---|---|
max_iterations |
int | 3 | Maximum number of loop iterations (1–100) |
on_exhausted |
string | null |
Node to route to when the limit is reached. If null, the branch terminates with a log warning. |
The framework tracks iteration counts in state automatically (key: _loop_<from>_<to>_iter). On exhaustion the counter is reset to 0.
See workflows/loop_workflow.yaml for a runnable writer→reviewer→finalizer loop.
Agent Retry Config
For transient errors (API timeouts, rate limits), agents can retry automatically before the workflow fails:
agents:
researcher:
model: smart
instruction: "Research this topic: {{state.user_input.topic}}"
retry:
max_retries: 3 # 1–10 additional attempts (default: 3)
backoff: exponential # fixed | exponential
delay_seconds: 1.0 # base delay between retries
| Field | Type | Default | Description |
|---|---|---|---|
max_retries |
int | 3 | Number of retry attempts after the first failure (1–10) |
backoff |
string | fixed |
fixed — constant delay; exponential — delay doubles each attempt |
delay_seconds |
float | 1.0 | Base delay in seconds (≥ 0) |
If all retries are exhausted, the error info is written to state._error_<agent_name> and the workflow can route via on_error edges.
Error Routing
Edges with on_error: true fire only when the source node fails (after exhausting all retries). When a node has both normal and on_error edges, the framework injects a unified error router — only one path fires (success or error, never both).
Basic error routing:
edges:
- from: researcher
to: writer # success path
- from: researcher
to: error_handler
on_error: true # fires on any error
Typed error routing — match on exception class name (error_type) and/or a regex on the error message (error_match). Edges are evaluated in declaration order; condition: default forces last:
edges:
- from: api_caller
to: success_handler
- from: api_caller
to: timeout_handler
on_error: true
error_type: TimeoutError # exact match on exception class name
- from: api_caller
to: rate_limit_handler
on_error: true
error_match: "rate.?limit" # regex match on error message
- from: api_caller
to: generic_error_handler
on_error: true
condition: default # catch-all fallback among error edges
| Field | Type | Default | Description |
|---|---|---|---|
on_error |
bool | false |
Route only on failure (after all retries) |
error_type |
string | null |
Exact match on exception class name (e.g. TimeoutError) |
error_match |
string | null |
Regex pattern matched against the error message |
condition: default |
— | — | Catch-all fallback; evaluated last regardless of declaration order |
When both error_type and error_match are set on one edge, both must match. An edge with neither is a wildcard that catches any error (original behavior — backwards-compatible). If no typed edge matches and there is no condition: default, the workflow terminates with a logged warning.
The error info in state (state._error_<agent_name>):
{
"error_type": "TimeoutError",
"error_message": "Request timed out after 30s",
"attempts": 4
}
See workflows/typed_errors.yaml for a runnable example.
Parallel / Fan-Out Edges
Send work to multiple nodes concurrently using to: [list] with parallel: true:
edges:
- from: planner
to: [researcher_a, researcher_b, researcher_c]
parallel: true
join: synthesizer # wait for all three, then proceed
| Field | Type | Default | Description |
|---|---|---|---|
to |
string | list[string] |
— | Single target or list of fan-out targets |
parallel |
bool | false |
Must be true when to is a list |
join |
string | null |
Barrier node — the workflow proceeds to this node only after all fan-out targets have written their output to state |
The framework auto-generates an invisible join node that polls ctx.state for all source outputs before routing to the join target.
Rules:
parallel: truerequirestoto be a list.joinrequirestoto be a list.loopis not compatible with fan-out edges (to: [list]).- Fan-out edges are always unconditional (no
condition:allowed).
Sub-Agents
Sub-agents let a parent agent delegate work to specialist agents at runtime — the parent LLM decides which specialist to call, rather than the workflow graph. This is complementary to workflow edges: use edges for deterministic pipelines, use sub-agents for LLM-driven delegation.
How it works
Sub-agents are declared under the parent in the agents block and are not listed in workflow.nodes. They are built as plain ADK Agent instances and passed to the parent via ADK's native sub_agents parameter. ADK automatically wires them as tools the parent LLM can invoke.
YAML fields
| Field | Type | Default | Description |
|---|---|---|---|
sub_agents |
list[str] |
[] |
Names of agents to register as sub-agents of this agent |
mode |
chat | task | single_turn | null |
null |
How the sub-agent is exposed to the parent LLM |
description |
str | null |
null |
Shown to the parent LLM to decide delegation — strongly recommended for sub-agents |
disallow_transfer_to_parent |
bool |
false |
Prevent the sub-agent from transferring back to parent |
disallow_transfer_to_peers |
bool |
false |
Prevent the sub-agent from transferring to sibling agents |
parallel_worker |
bool | null |
null |
Sub-agents only — allows parent to invoke multiple specialists concurrently |
Modes:
single_turn— sub-agent is wrapped as a callable tool; invoked once and returns a result immediately. Best for specialist tasks.chat— sub-agent is reachable viatransfer_to_agent; the LLM can have a back-and-forth conversation with it.task— sub-agent runs as a background task.
Example
agents:
search_specialist:
model: smart_model
description: "Retrieves factual information about a topic from the web."
instruction: "Search for factual information on the given topic."
mode: single_turn # exposed as a callable tool to the parent
parallel_worker: true # may run concurrently with sibling specialists
analysis_specialist:
model: smart_model
description: "Identifies themes and insights from a body of text."
instruction: "Identify the three most important themes from the findings."
mode: single_turn
parallel_worker: true
coordinator:
model: smart_model
static_instruction: "You are a research coordinator. Be decisive and concise."
instruction: |
You coordinate research about: {{state.user_input.topic}}.
Delegate to search_specialist for facts, then analysis_specialist for themes.
Synthesize a final 200-word brief.
output_key: final_brief # write result to state["final_brief"]
sub_agents:
- search_specialist
- analysis_specialist
workflow:
nodes: [coordinator] # only the parent is a workflow node
edges: []
entry: coordinator
Rules and constraints
- Sub-agents must not appear in
workflow.nodes— they live inside their parent, not the graph. - Sub-agent names must reference agents defined in the
agentsdict. - Sub-agent instructions are static —
{{state.x}}templates are not resolved (only the parent's instruction supports templating). - Circular sub-agent references (A→B→A) are rejected at load time.
- Nested sub-agents are supported: a sub-agent can itself have sub-agents. Build order is resolved automatically.
parallel_workeris only valid on sub-agents; setting it on a workflow node raises a validation error.
ADK 2.0 Agent Efficiency & Flexibility Params
These params expose additional ADK 2.0 LlmAgent knobs directly in YAML.
static_instruction — prompt caching
Content that never changes across turns. ADK moves this to a cacheable position in the request, reducing latency and cost on repeated invocations.
agents:
analyst:
model: my_model
static_instruction: |
You are a senior data analyst. Follow these rules strictly:
1. Always cite sources.
2. Use bullet points for findings.
instruction: "Analyse the data in {{state.analyst_input}}."
# -- OR load from a file --
# static_instruction_file: prompts.analyst_system
generate_content_config — per-agent generation control
Override temperature, token limits, safety settings, and more on a per-agent basis. These take effect at the model-call level and override any model-level temperature/max_tokens set on the shared ModelConfig.
agents:
deterministic_agent:
model: my_model
instruction: "Extract structured data."
generate_content_config:
temperature: 0.0 # fully deterministic
max_output_tokens: 512
seed: 42
response_mime_type: "application/json"
stop_sequences: ["---END---"]
safety_settings:
- category: HARM_CATEGORY_DANGEROUS_CONTENT
threshold: BLOCK_NONE
creative_agent:
model: my_model
instruction: "Write a short story."
generate_content_config:
temperature: 1.2
top_p: 0.95
presence_penalty: 0.3
Valid category values (from google.genai.types.HarmCategory): HARM_CATEGORY_HARASSMENT, HARM_CATEGORY_HATE_SPEECH, HARM_CATEGORY_SEXUALLY_EXPLICIT, HARM_CATEGORY_DANGEROUS_CONTENT, etc.
Valid threshold values: BLOCK_NONE, BLOCK_ONLY_HIGH, BLOCK_MEDIUM_AND_ABOVE, BLOCK_LOW_AND_ABOVE, OFF.
thinking — per-agent BuiltInPlanner (Gemini 2.5+)
Enables ADK's BuiltInPlanner with a Gemini thinking budget. This controls how many tokens Gemini spends on internal reasoning before producing its response.
agents:
reasoning_agent:
model: gemini_model
instruction: "Solve this multi-step problem: {{state.problem}}"
thinking:
thinking_budget: 2048 # 0=disabled, -1=auto, >0=explicit token budget
include_thoughts: false # true to surface reasoning in the response
Note:
thinkingapplies only to Gemini models that support thinking (e.g.gemini-2.5-flash). For Anthropic extended-thinking or OpenAI reasoning_effort, use the model-levelthinking:config onModelConfig.
input_schema — typed agent-as-tool input
When a sub-agent is invoked as a tool by its parent, input_schema constrains what the parent LLM can pass in.
agents:
typed_specialist:
model: my_model
instruction: "Process the query."
mode: single_turn
input_schema: mypackage.schemas.SearchInput # dotted import path to a Pydantic BaseModel
output_key — custom state key
By default, a workflow agent's output is written to state[agent_name]. Override with output_key to use a different key:
agents:
summariser:
model: my_model
instruction: "Summarise {{state.raw_text}}."
output_key: summary # downstream agents read state["summary"]
Full example
See workflows/agent_overrides.yaml for a complete workflow demonstrating all new params together.
External Prompt Files
For longer prompts, store the text in a separate file and reference it with instruction_file:
agents:
researcher:
model: local
instruction_file: prompts.research_assistant__researcher
- Dots are folder separators;
.mdis appended automatically. The ref above resolves to<cwd>/prompts/research_assistant__researcher.md. - Resolution is from the project root (cwd where the CLI runs), not the YAML file's directory.
- The recommended layout is a top-level
prompts/directory at the repo root, with files named<workflow>__<agent>.md. {{state.x}}and{{state.x.y.z}}template syntax works identically inside prompt files — resolved at node-execution time, not load time.instructionandinstruction_fileare mutually exclusive; exactly one must be set per agent.
State Templating
Every node's output is written to ctx.state[<agent_name>] automatically (via ADK's output_key).
Reference prior outputs in any instruction using {{state.<dotted.path>}}:
instruction: |
The topic is: {{state.user_input.topic}}
Based on this research: {{state.researcher}}
Write a summary.
user_input is always available from the --input JSON argument.
Conditional Blocks
Use {{#if state.key}}…{{/if}} to include a section only when the key exists in state and is truthy. This is especially useful in loops where a node's output may not exist on the first iteration:
instruction: |
Write a short paragraph about: {{state.user_input.topic}}
{{#if state.reviewer}}
The reviewer provided this feedback — incorporate it:
{{state.reviewer}}
{{/if}}
- The inner content (including any
{{state.x}}templates) is included only when the condition key exists and is truthy. - If the key is missing or falsy (empty string,
null,0,false), the entire block is removed. - Conditional blocks are resolved before value templates — so
{{state.x}}references inside the block are safe. - Nesting conditional blocks is not supported.
Rules:
- Double-brace syntax
{{state.x}}— resolved by Modular Agent Designer at node execution time. - Single-brace
{key}— passed through to ADK's native state injection. - Missing key in a
{{state.x}}reference (outside a conditional block) →StateReferenceErrornaming the exact missing path and available keys. No silent empty strings.
Model Providers
All providers route through LiteLlm. API keys are read from environment variables only — never from YAML.
| Provider | model prefix | Env var required |
|---|---|---|
| Ollama | ollama/ |
OLLAMA_API_BASE (default: http://localhost:11434) |
| Anthropic | anthropic/ |
ANTHROPIC_API_KEY |
| Google Gemini | gemini/ |
GOOGLE_API_KEY |
| OpenAI | openai/ |
OPENAI_API_KEY |
Provider validation: the model string must start with the expected prefix (e.g., gemini/gemini-2.5-flash for Google). This is checked at load time.
Bundled Tools
The following tools ship with the framework and can be referenced by short name using type: builtin + name::
| Name | Description |
|---|---|
fetch_url |
Async HTTP GET — returns response body as text. On HTTP error returns ERROR: … string (never raises). |
http_get_json |
Async HTTP GET — parses response as JSON and returns a dict. On error returns {"error": "…"}. |
read_text_file |
Read a UTF-8 text file at a path relative to CWD. Rejects absolute paths and .. traversal. Returns file contents or an ERROR: … string. |
tools:
fetch:
type: builtin
name: fetch_url # resolves to modular_agent_designer.tools.fetch_url
fetch_json:
type: builtin
name: http_get_json
read_file:
type: builtin
name: read_text_file
agents:
researcher:
model: my_model
instruction: "Fetch https://example.com and summarize it."
tools: [fetch]
You can also reference bundled tools by dotted path if you prefer:
tools:
fetch:
type: builtin
ref: modular_agent_designer.tools.fetch_url
MCP Tools
Any tool with type: mcp_stdio, mcp_sse, or mcp_http is wired as a McpToolset — the full ADK MCP integration. Agents reference MCP tools by their YAML alias name exactly like any other tool:
tools:
fs:
type: mcp_stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/sandbox"]
tool_name_prefix: fs
agents:
writer:
model: my_model
instruction: "List the files and write a summary."
tools: [fs]
Secrets: header/env values containing ${VAR} are replaced with the corresponding environment variable at load time. If the variable is unset the load fails immediately with a clear error — credentials are never stored in YAML.
Lifecycle: MCP connections are opened lazily on first use and closed automatically by the ADK Runner. No teardown code is required.
See workflows/mcp_example.yaml for a runnable reference showing all three transports together.
Using Tools from a Local Directory (No Install Required)
Drop a tools/ package in your project root and reference callables directly from YAML — no pip install needed.
Layout:
your-project/
tools/
__init__.py # empty — makes tools/ a Python package
text_tools.py # your custom functions
workflows/
my_workflow.yaml
YAML reference:
tools:
word_count:
type: python
ref: tools.text_tools.word_count
How it works: modular-agent-designer automatically prepends the current working directory (CWD) and the YAML file's directory to sys.path at startup. Run the CLI from your project root and any local package is importable.
# Run from the project root — tools/ is on sys.path automatically
uv run modular-agent-designer run workflows/my_workflow.yaml --input '{"text": "hello"}'
See tools/text_tools.py and workflows/local_tools_example.yaml for a working reference.
Using Tools from External Packages
Any Python callable in an installed package can be used as a tool — no changes to modular_agent_designer are needed:
tools:
forecast:
type: python
ref: mycompany_tools.weather.get_forecast # any pip-installed package
Requirements:
- The package must be installed in the same Python environment that runs
modular-agent-designer(uv pip install -e ./your_pkg --prerelease=allowfor a local editable install). - The ref must resolve to a callable (function, async function, or instance with
__call__). Pointing at a module, a dict, or a non-callable attribute will raise aTypeErrorat load time naming the alias and the resolved type.
See workflows/external_tool_example.yaml for a runnable example with setup instructions.
Adding a New Agent
Edit only the YAML — no Python changes:
- Add a model alias (if needed) to
models:. - Add the agent under
agents:withmodel:,instruction:, and optionaltools:. - Add the agent name to
workflow.nodes. - Add edges connecting it to the chain under
workflow.edges.
Adding a Custom BaseNode
For logic that isn't a plain LLM call (branching, loops, side effects):
agents:
my_router:
type: node
ref: my_package.nodes.RouterNode # BaseNode subclass or @node-decorated function
config: # optional; forwarded as kwargs to the constructor
threshold: 0.8
label: primary
# my_package/nodes.py
from google.adk.workflow import BaseNode, node
from google.adk import Context, Event
class RouterNode(BaseNode):
def __init__(self, name: str, threshold: float = 0.5, label: str = "default"):
super().__init__(name=name)
self.threshold = threshold
self.label = label
async def run(self, ctx: Context, node_input):
# Read/write ctx.state, call ctx.run_node(), yield Events
if "keyword" in node_input:
yield Event(route="path_a")
else:
yield Event(route="path_b")
The config: mapping is passed as keyword arguments to the BaseNode subclass constructor (alongside name). This lets you parameterise a node in YAML without writing a new subclass per configuration. @node-decorated plain functions ignore config:.
Custom nodes handle their own state writes.
Running Tests
uv run pytest # all tests (e2e skipped if Ollama unreachable)
uv run pytest -k "not ollama" # unit tests only
uv run pytest tests/test_end_to_end_ollama.py -v # e2e (requires Ollama)
Using as a Library
You can integrate Modular Agent Designer into your own Python applications.
import asyncio
import json
from modular_agent_designer import load_workflow, build_workflow, run_workflow_async
async def main():
# 1. Load the workflow configuration
cfg = load_workflow("workflows/hello_world.yaml")
# 2. Build the workflow graph
workflow = build_workflow(cfg)
# 3. Define the input data
input_data = {"topic": "AI Agents"}
# 4. Run the workflow asynchronously
# This handles session management and state injection
final_state = await run_workflow_async(workflow, input_data)
print(json.dumps(final_state, indent=2))
if __name__ == "__main__":
asyncio.run(main())
CLI Reference
create — scaffold a new agent project
modular-agent-designer create <agent_name> [--dir <parent>] [--force]
agent_name— name of the agent folder to create; must be a valid Python identifier--dir— parent directory to create the folder in (defaults to CWD)--force— overwrite existing files in the target folder
Creates <agent_name>/ containing a ready-to-run YAML workflow, a Python entry point, a tools/ package, and a per-agent README.
run — execute a workflow
modular-agent-designer run <yaml_path> (--input '<json>' | --input-file <path>) [options]
yaml_path— path to the workflow YAML file--input '<json>'— JSON object available asstate.user_inputin templates (mutually exclusive with--input-file)--input-file PATH— read input JSON from a file; use-to read from stdin--mlflow EXPERIMENT_ID— Enable MLflow tracing via OTLP--log-level LEVEL— set logging verbosity:DEBUG,INFO,WARNING, orERROR
Exactly one of --input or --input-file is required. Output: final session state as pretty-printed JSON.
# Inline JSON
uv run modular-agent-designer run workflows/hello_world.yaml --input '{"topic":"AI"}'
# From a file
uv run modular-agent-designer run workflows/hello_world.yaml --input-file input.json
# From stdin
echo '{"topic":"AI"}' | uv run modular-agent-designer run workflows/hello_world.yaml --input-file -
# With debug logging
uv run modular-agent-designer run workflows/hello_world.yaml --input '{"topic":"AI"}' --log-level DEBUG
validate — check a workflow without running it
modular-agent-designer validate <yaml_path> [--skip-build]
yaml_path— path to the workflow YAML file--skip-build— only validate the YAML schema; skip the build step (avoids API-key checks — useful in CI without secrets)
Exits 0 on success, 1 on any error. Useful for CI pipelines and pre-commit hooks.
# Full validation (schema + build — requires API keys)
uv run modular-agent-designer validate workflows/research_assistant.yaml
# Schema-only (no API keys required)
uv run modular-agent-designer validate workflows/research_assistant.yaml --skip-build
list — inspect a workflow's structure
modular-agent-designer list <yaml_path>
Loads the YAML and prints a human-readable summary of models, tools, skills, agents, and the workflow graph (entry point, nodes, edges with conditions). No LLM calls or API keys required.
diagram — visualize a workflow as a Mermaid flowchart
modular-agent-designer diagram <yaml_path> [--output PATH]
yaml_path— path to the workflow YAML file--output PATH— write the diagram to a file instead of stdout
Loads the workflow config (no LLM calls, no API keys required) and emits a Mermaid flowchart TD to stdout. Paste the output into mermaid.live or any Markdown renderer (GitHub, Notion, Obsidian) to get an instant visual of your node/edge graph.
# Print to terminal — pipe into a .mmd file or paste into mermaid.live
uv run modular-agent-designer diagram workflows/conditional_workflow.yaml
# Write directly to a file
uv run modular-agent-designer diagram workflows/complex_conditions.yaml --output diagram.mmd
Example output for conditional_workflow.yaml:
flowchart TD
START((start))
classifier["classifier<br/>(local_fast)"]
tech_expert["tech_expert<br/>(local_fast) · chat"]
creative_expert["creative_expert<br/>(local_fast)"]
START --> classifier
classifier -. "technical" .-> tech_expert
classifier -. "creative" .-> creative_expert
What gets rendered:
| Element | Mermaid representation |
|---|---|
| Workflow entry | Virtual START node with solid arrow to the entry node |
| LLM agent | Rectangle with name (model_alias) label; appends · chat for chat mode |
Custom BaseNode |
Hexagon with name (ref) label |
| Unconditional edge | Solid arrow --> |
| String / integer condition | Dashed arrow with the value as label |
| List condition (OR logic) | Dashed arrow with values joined by | |
eval condition |
Dashed arrow with eval: <expression> label (truncated to 40 chars) |
default fallback |
Dashed arrow with default label |
| Sub-agents | Subgraph cluster under the parent node, with dotted edges |
AI Coding Assistant Skills
The ai_skills/ directory contains five task-specific skills for Claude Code and Gemini CLI. Both tools use the same ADK SKILL.md format and auto-discover skills from a .claude/skills/ or .gemini/skills/ directory in your project root.
| Task | Skill |
|---|---|
| Full reference / first time using the library | mad-overview |
| Building a new workflow from scratch | mad-create-workflow |
| Adding tools (builtin, python, MCP stdio/SSE/HTTP) | mad-tools |
| Conditional routing, loops, error routing, parallel edges | mad-routing |
| Sub-agents, skills, output schemas, custom nodes | mad-sub-agents |
Claude Code
1. Copy skills into the Claude Code discovery directory:
mkdir -p .claude/skills
for skill in ai_skills/mad-*/; do cp -r "$skill" .claude/skills/; done
This creates .claude/skills/mad-overview/, .claude/skills/mad-create-workflow/, etc. Commit these to version control so every contributor gets them automatically.
2. Start a Claude Code session — skills are auto-loaded:
claude
Claude Code reads each skill's name and description at session start and activates the relevant skill based on your request.
3. Invoke a skill manually:
/mad-overview
/mad-create-workflow
/mad-tools
/mad-routing
/mad-sub-agents
Gemini CLI
1. Copy skills into the Gemini CLI discovery directory:
mkdir -p .gemini/skills
for skill in ai_skills/mad-*/; do cp -r "$skill" .gemini/skills/; done
Commit .gemini/skills/ to version control to share skills across your team.
2. Start a Gemini CLI session — skills are auto-discovered:
gemini
The model reads each skill's name and description at startup. When your prompt matches a skill, it calls activate_skill to pull in the full instructions automatically. Only the metadata is loaded upfront, saving context tokens.
3. Invoke a skill manually:
/mad-overview
/mad-routing
User-level install (available in all projects)
To make the skills available globally — not just in this project — copy them to your home directory:
# Claude Code
mkdir -p ~/.claude/skills
for skill in ai_skills/mad-*/; do cp -r "$skill" ~/.claude/skills/; done
# Gemini CLI
mkdir -p ~/.gemini/skills
for skill in ai_skills/mad-*/; do cp -r "$skill" ~/.gemini/skills/; done
See ai_skills/README.md for the full skill reference.
Required Env Vars Summary
# Ollama (optional — defaults to http://localhost:11434)
export OLLAMA_API_BASE=http://localhost:11434
# Anthropic
export ANTHROPIC_API_KEY=sk-ant-...
# Google Gemini
export GOOGLE_API_KEY=AIza...
# OpenAI
export OPENAI_API_KEY=sk-...
Architecture Notes
- ADK version:
google-adk[extensions]==2.0.0a3(alpha — breaking changes expected) - State injection: Initial state from
--inputis set viaInMemorySessionService.create_session(state={"user_input": ...})before the workflow runs - Template timing:
{{state.x.y}}is resolved at node execution time (not workflow construction time) usingctx.state.to_dict(). Conditional blocks{{#if state.key}}…{{/if}}are resolved first, then value templates. - AgentNode wrapper: Each YAML agent becomes a
@node(rerun_on_resume=True)async generator — required by ADK when callingctx.run_node(). Optionally wrapped in a retry loop whenretry:config is set. - Branching: Supports literal matching, list-based OR logic,
evalexpressions,defaultcatch-alls,switch/casesugar (expanded at load time), and dynamic destinations (to: "{{state.x}}"resolved at runtime). - Loops: Controlled via
loop:config on edges. Iteration counters are tracked in state (_loop_<from>_<to>_iter). Accidental cycles (withoutloop:) are rejected at load time. - Error routing:
on_error: trueedges fire only when a node fails (after all retries). Supports typed matching viaerror_type(exact exception class) anderror_match(regex on message). A unified error router ensures exactly one path fires (success or error). - Parallel / Fan-out:
to: [list]withparallel: truedispatches to multiple nodes. An auto-generated join node (whenjoin:is set) waits for all fan-out targets before continuing.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file modular_agent_designer-0.6.4.tar.gz.
File metadata
- Download URL: modular_agent_designer-0.6.4.tar.gz
- Upload date:
- Size: 341.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
95f655bda0806f6f4e397daa0c67e970a794cebeb423ec48d5fd530d188fadd2
|
|
| MD5 |
9694f1739c471c7105f1c3def80f5462
|
|
| BLAKE2b-256 |
e7f37d372518b2a769136806d0234f2082444cf1c5842a072b9e79f6932c4a87
|
File details
Details for the file modular_agent_designer-0.6.4-py3-none-any.whl.
File metadata
- Download URL: modular_agent_designer-0.6.4-py3-none-any.whl
- Upload date:
- Size: 58.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c5f655a14afc385382fc4510afd5a21231962b4a7671e5d78846d03d2196b5d5
|
|
| MD5 |
7c3a97d76fc3653167425a5cbeee7bd5
|
|
| BLAKE2b-256 |
61bd49274c97ec13220797d45fef96c9c8be7fc9791e63e728ce088e41466289
|