Create a custom agent manually
This guide walks you through building a custom agent package in Enterprise h2oGPTe without Agent Builder. You create the required files in Steps 1 through 6, run local checks in Step 7, and then upload the package and configure keys and MCP tools.
This page covers the manual file layout and registration flow. For UI tasks such as Assign Keys and Assign Tools, start with Custom agents. To generate agent and MCP packages from chat, see Deploy a custom agent with a local MCP tool.
Prerequisites​
Before you begin, confirm the following:
- You have read Custom agents and understand the Agents page, uploads, and key and tool assignment.
- You have Python 3.10 installed, which matches the platform runtime.
- You know which framework your agent targets:
langgraph,crewai, oropenai_agents_sdk.
Step 1: Prepare the ZIP package structure​
Every agent package follows a consistent folder layout. Place all required files in a single top-level folder inside the ZIP. The folder name becomes the agent name unless you override it at upload time.
my_agent/
├── metadata.json # Required – Step 2: Agent metadata and function signature
├── custom_agents.py # Required – Step 3: Main implementation
├── envs.json # Required – Step 4: Environment variable definitions
├── test_custom_agents.py # Required – Step 5: Test suite
├── requirements.txt # Required – Step 6: Python dependencies
├── description.md # Optional (required for the claude agent type)
└── README.md # Optional
For claude type agents, include an additional {agent_name}.md file (for example, my_agent.md) in the same folder.
Step 2: Create metadata.json​
The metadata.json file defines your agent's identity and function signature. It has three sections: agent_info, function_info, and optional MCP fields.
Schema​
{
"agent_info": {
"name": "my_agent",
"description": "What this agent does",
"framework": "langgraph",
"created": "2026-01-15T00:00:00Z"
},
"function_info": {
"name": "my_function",
"is_async": false,
"return_type": "str",
"parameter_names": ["query", "chat_history"],
"parameters": [
{
"name": "query",
"type": "str",
"required": false,
"default": "Hello",
"description": "The user query"
},
{
"name": "chat_history",
"type": "Optional[List[Dict[str, str]]]",
"required": false,
"default": null,
"description": "Conversation history in OpenAI format"
}
]
},
"mcp_servers": ["server-name"],
"mcp_config": {
"mcp_dir": "${MCP_DIR}",
"server-name": {
"path_constant": "SERVER_NAME_MCP_SERVER_PATH",
"path_value": "${MCP_DIR}/server-name/server.py",
"transport": "stdio"
}
}
}
default is optional in metadata. The validator only requires name, type, required, and description for each parameter. The default field in metadata.json is separate from the Python default value you assign in custom_agents.py. You can omit default from a parameter object; the platform does not require it.
{
"name": "query",
"type": "str",
"required": false,
"description": "The user query"
}
Field reference​
agent_info​
| Field | Required | Description |
|---|---|---|
name | Yes | Agent identifier (alphanumeric characters and underscores only) |
description | Yes | Human-readable summary of the agent |
framework | Yes | One of: langgraph, crewai, openai_agents_sdk |
created | Yes | ISO 8601 timestamp (for example, 2026-01-15T00:00:00Z) |
function_info​
| Field | Required | Description |
|---|---|---|
name | Yes | Entry-point function name (must match the function in custom_agents.py) |
is_async | Yes | Set to true for an async function |
return_type | Yes | Return type: str, dict, or None |
parameter_names | Yes | Ordered list of parameter name strings |
parameters | Yes | List of parameter objects (each requires name, type, required, description; default is optional) |
Optional MCP fields​
| Field | Required | Description |
|---|---|---|
mcp_servers | No | List of MCP server names the agent depends on |
mcp_config | No | MCP server path and transport configuration |
Every parameter's name value must appear in the parameter_names list.
Framework string: Set framework to openai_agents_sdk when you target the OpenAI or Claude agent types. Do not use the shorthand openai in metadata.json.
Step 3: Implement custom_agents.py​
The custom_agents.py file contains your agent's core logic. Follow these conventions:
- Match the entry-point function name to
function_info.nameinmetadata.json. - Accept
**kwargsor explicit parameters that matchparameter_names. - Accept a
chat_historyparameter as a message list in OpenAI format. - Give every parameter a default value so the agent can run without arguments.
- Call
print(..., flush=True)for progress messages so the UI can stream output. - Provide a
main()function withargparsefor command-line execution.
"""My custom agent."""
import argparse
from typing import Optional, List, Dict
def my_function(
query: str = "Hello",
chat_history: Optional[List[Dict[str, str]]] = None,
) -> str:
"""Entry point. Name must match function_info.name in metadata.json."""
print("Starting agent...", flush=True)
# Your agent logic here
result = f"Processed: {query}"
print("Done!", flush=True)
return result
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--query", default="Hello")
args = parser.parse_args()
result = my_function(query=args.query)
print(result)
if __name__ == "__main__":
main()
Streaming output: Call print(..., flush=True) for progress messages. Without flush=True, output buffers and does not appear in the chat UI during a run.
Chat history format​
The platform passes conversation history in OpenAI format:
[
{"role": "user", "content": "What's the weather?"},
{"role": "assistant", "content": "It's sunny in Paris."},
{"role": "user", "content": "And tomorrow?"}
]
Step 4: Define envs.json​
The envs.json file declares the environment variables your agent needs. The platform reads this file and creates a corresponding row in the Assign Keys UI for each entry.
{
"CUSTOM_AGENT_MODEL": "claude-sonnet-4-20250514",
"CUSTOM_AGENT_BASE_URL": null,
"CUSTOM_AGENT_API_KEY": null,
"CUSTOM_AGENT_TIMEOUT": "30"
}
Value conventions​
| Value | Meaning | Requirement type |
|---|---|---|
null | No default; optional for the user to provide | optional_no_default |
"some_value" | Default provided; the user can override it | optional_with_default |
"os.environ/VAR_NAME" | Required from the host environment | required |
Read these values in your agent code with os.getenv():
import os
model = os.getenv("CUSTOM_AGENT_MODEL", "claude-sonnet-4-20250514")
api_key = os.getenv("CUSTOM_AGENT_API_KEY", "")
Step 5: Add test_custom_agents.py​
Write tests that call the entry-point function directly. Include coverage for default parameters, expected inputs, and edge cases.
"""Tests for my_agent."""
import pytest
from custom_agents import my_function
def test_function_exists():
"""Verify the entry point function exists."""
assert callable(my_function)
def test_basic_query():
"""Test with a basic query."""
result = my_function(query="test input")
assert isinstance(result, str)
assert len(result) > 0
def test_default_parameters():
"""Test that defaults work without arguments."""
result = my_function()
assert isinstance(result, str)
def test_chat_history():
"""Test with conversation history."""
history = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
result = my_function(query="Follow up", chat_history=history)
assert isinstance(result, str)
Step 6: Add requirements.txt​
List your Python dependencies. Do not include standard library modules such as os, json, or typing.
langgraph>=0.2.0
langchain-core>=0.3.0
langchain-openai>=0.2.0
pydantic>=2.0.0,<3.0.0
pytest>=7.0.0
Step 7: Test before upload​
Run these checks on your workstation before you upload the ZIP file. Fix JSON and packaging errors here before debugging upload failures in the UI.
Pre-upload checklist: Run these steps in order inside your agent's directory.
- Install dependencies:
pip install -r requirements.txt - Run the agent directly:
python custom_agents.py --query "test" - Run tests:
python -m pytest test_custom_agents.py -v - Validate
metadata.json:python -m json.tool metadata.json - Validate
envs.json:python -m json.tool envs.json - Check the ZIP structure:
zip -r my_agent.zip my_agent/ && unzip -l my_agent.zip
What comes next: You can upload after Step 7 once all checks pass. Read Map agent types to frameworks before uploading to keep framework and agent_type aligned. Read Integrate MCP tools and Configure environment variables and API keys when your agent references tools or secrets.
Map agent types to frameworks​
Each agent type corresponds to a framework value in metadata.json:
| Agent type | framework in metadata.json | Description |
|---|---|---|
langgraph | "langgraph" | LangGraph agents with stateful graph workflows |
crewai | "crewai" | CrewAI multi-agent orchestration |
openai | "openai_agents_sdk" | OpenAI Agents SDK |
claude | "openai_agents_sdk" | Claude via OpenAI Agents SDK (requires an {agent_name}.md file) |
Use this mapping from framework to agent_type at upload time:
FRAMEWORK_TO_AGENT_TYPE = {
"langgraph": "langgraph",
"crewai": "crewai",
"openai_agents_sdk": "openai",
}
When you upload an agent, set agent_type to langgraph, crewai, openai, or claude. The framework field in metadata.json must match that choice as shown in the table above.
claude agents use openai_agents_sdk: Both openai and claude agent types set framework to "openai_agents_sdk" in metadata.json. The differences are the agent_type you pass at upload time and the extra {agent_name}.md file that claude requires.
Integrate MCP tools​
MCP (Model Context Protocol) tools are servers that expose functions your agent can call at runtime. Enterprise h2oGPTe supports local MCP tools that you upload as a ZIP file.
For UI steps and MCP ZIP packaging rules, see Create local MCP tools.
Create an MCP tool​
An MCP tool is a Python server built with the mcp library. Here is a minimal server:
"""My MCP Server."""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-tool")
@mcp.tool()
def my_tool_function(param: str) -> str:
"""Description of what this tool does."""
return f"Result for {param}"
if __name__ == "__main__":
mcp.run()
Structure the MCP tool ZIP​
Package your MCP tool as a ZIP archive with this layout:
my-tool/
├── server.py # Required: MCP server implementation
├── requirements.txt # Optional: Python dependencies
├── envs.json # Optional: Environment variables
└── description.md # Optional: Description shown in the UI
Upload an MCP tool​
In the UI:
- Open the Agents page.
- Open the Tools tab.
- Click New Tool.
- Select Local MCP.
- Upload your ZIP file.
With the Python API: Use the add_custom_agent_tool method. For the full endpoint reference, see Custom Agents API.
Link MCP tools to an agent​
Reference MCP tools in your agent's metadata.json:
{
"mcp_servers": ["my-tool"],
"mcp_config": {
"mcp_dir": "${MCP_DIR}",
"my-tool": {
"path_constant": "MY_TOOL_MCP_SERVER_PATH",
"path_value": "${MCP_DIR}/my-tool/server.py",
"transport": "stdio"
}
}
}
The platform validates each name in mcp_servers against registered tools at upload time:
- Custom tools (
remote_mcporlocal_mcp): The platform requires an exact name match. - Built-in tools: The platform uses a flexible match that ignores the
.pyextension.
After upload, manage tool associations in the UI (Agents > select an agent > Tools) or through the Custom Agents API.
MCP names must exist: Every name in mcp_servers must match a tool already registered in your workspace. Custom tools require an exact match. The upload fails if a name is missing or misspelled.
Use MCP tools in agent code​
LangGraph (with langchain-mcp-adapters):
from langchain_mcp_adapters.client import MultiServerMCPClient
async def _get_mcp_tools(server_path):
async with MultiServerMCPClient(
{"weather-mcp": {"command": "python", "args": [server_path], "transport": "stdio"}}
) as client:
tools = client.get_tools()
return tools
OpenAI Agents SDK:
from agents.mcp import MCPServerStdio
async with MCPServerStdio(command="python", args=[server_path]) as mcp_server:
agent = Agent(name="my_agent", mcp_servers=[mcp_server])
Configure environment variables and API keys​
Create keys​
In the UI:
- Open the Agents page.
- Open the Authentication tab.
- Click + New Key.
With the Python API: Use the add_agent_key method. For the full endpoint reference, see Custom Agents API.
Secrets: Do not embed API keys or other secrets in agent source code or in public repositories. Create keys in the platform and map them with Assign Keys to the variables declared in envs.json.
Associate keys with an agent​
After you upload an agent, its envs.json file defines placeholder key slots. Associate actual keys with those slots to provide values at runtime.
In the UI:
- Open the Agents page.
- Select an agent.
- Click Assign Keys.
With the Python API: Use the create_custom_agent_key_association method. For the full endpoint reference, see Custom Agents API.
Upload and manage agents​
Complete Steps 1 through 7 (see Step 7: Test before upload). Then upload the ZIP file.
After upload: If your agent declares environment variables or MCP servers, complete Assign Keys and Assign Tools on the Agents page before testing in Chat. Skip those steps only when envs.json is empty ({}) and mcp_servers is absent.
Upload in the UI​
- Open the Agents page.
- Open the Agents tab.
- Click + New Agent.
- Select your ZIP file and enter a name.
- After upload, assign keys, link tools, and test as needed.
Upload with the Python API​
Use the add_custom_agent method from the Python SDK. For the full endpoint reference, parameters, and examples, see Custom Agents API.
Use a custom agent in chat​
After you upload and configure the agent, it appears in the Agent Type list on the Chat page. Select the agent and start a conversation. The platform passes the query and chat history to your entry-point function.
Explore complete examples​
These examples provide full packages you can reuse or adapt. They progress from a minimal agent to one that depends on an MCP tool.
These listings focus on the files that vary by scenario. Add optional files from Step 1 (such as description.md or README.md) when your team requires them.
Simple agent: number_adder_agent​
A minimal LangGraph agent that adds two numbers. It does not call an LLM or an MCP server and uses LangGraph for state management only.
metadata.json:
{
"agent_info": {
"name": "number_adder",
"description": "A simple LangGraph agent that adds two numbers and returns the result as a formatted message.",
"framework": "langgraph",
"created": "2026-03-09T00:00:00Z"
},
"function_info": {
"name": "add_numbers",
"is_async": false,
"return_type": "str",
"parameter_names": ["a", "b", "chat_history"],
"parameters": [
{
"name": "a",
"type": "str",
"required": false,
"default": "5",
"description": "First number to add (as a string)"
},
{
"name": "b",
"type": "str",
"required": false,
"default": "3",
"description": "Second number to add (as a string)"
},
{
"name": "chat_history",
"type": "Optional[List[Dict[str, str]]]",
"required": false,
"default": null,
"description": "Optional chat history for conversation continuity (not used by this agent)"
}
]
}
}
envs.json:
{
"CUSTOM_AGENT_MODEL": null,
"CUSTOM_AGENT_BASE_URL": null,
"CUSTOM_AGENT_API_KEY": null
}
custom_agents.py:
"""Simple LangGraph agent that adds two numbers."""
import argparse
from typing import Any, Optional, List, Dict
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, END
class AdditionState(TypedDict):
a: str
b: str
result: str
error: str
def add_node(state: AdditionState) -> AdditionState:
"""Node that adds two numbers."""
try:
num_a = float(state["a"])
num_b = float(state["b"])
total = num_a + num_b
if total == int(total):
result_str = str(int(total))
else:
result_str = f"{total:.6f}".rstrip("0").rstrip(".")
return {
"a": state["a"],
"b": state["b"],
"result": f"The sum of {state['a']} and {state['b']} is {result_str}",
"error": "",
}
except (ValueError, TypeError) as e:
return {
"a": state["a"],
"b": state["b"],
"result": "",
"error": f"Error: Could not convert inputs to numbers - {str(e)}",
}
def build_addition_graph() -> Any:
"""Build a single node StateGraph: add -> END."""
graph = StateGraph(AdditionState)
graph.add_node("add", add_node)
graph.set_entry_point("add")
graph.add_edge("add", END)
return graph.compile()
def add_numbers(
a: str = "5",
b: str = "3",
chat_history: Optional[List[Dict[str, str]]] = None,
) -> str:
"""Entry point. Builds graph, invokes, returns result or error."""
print(f"Adding {a} + {b}...", flush=True)
graph = build_addition_graph()
result = graph.invoke({"a": a, "b": b, "result": "", "error": ""})
if result.get("error"):
return result["error"]
return result["result"]
def main() -> None:
parser = argparse.ArgumentParser(description="Add two numbers")
parser.add_argument("--a", default="5", help="First number")
parser.add_argument("--b", default="3", help="Second number")
args = parser.parse_args()
result = add_numbers(a=args.a, b=args.b)
print(result)
if __name__ == "__main__":
main()
test_custom_agents.py:
"""Tests for number_adder_agent."""
import pytest
from custom_agents import add_numbers
def test_function_exists():
"""Verify the entry point function exists and is callable."""
assert callable(add_numbers)
def test_default_parameters():
"""Test with default values (5 + 3 = 8)."""
result = add_numbers()
assert isinstance(result, str)
assert "8" in result
def test_integer_addition():
"""Test adding two integers."""
result = add_numbers(a="10", b="20")
assert "30" in result
def test_float_addition():
"""Test adding floating point numbers."""
result = add_numbers(a="1.5", b="2.5")
assert "4" in result
def test_negative_numbers():
"""Test adding negative numbers."""
result = add_numbers(a="-5", b="3")
assert "-2" in result
def test_invalid_input():
"""Test error handling for non-numeric input."""
result = add_numbers(a="abc", b="3")
assert "Error" in result
def test_chat_history_accepted():
"""Test that chat_history parameter is accepted."""
history = [{"role": "user", "content": "Add numbers"}]
result = add_numbers(a="1", b="2", chat_history=history)
assert isinstance(result, str)
requirements.txt:
langgraph>=0.2.0
langchain-core>=0.3.0
pytest>=7.0.0
ruff>=0.1.0
mypy>=1.0.0
Agent with MCP tool: weather_forecaster​
This example retrieves weather data through an MCP tool server. It demonstrates LLM integration, MCP usage, and multi-node graph workflows. The listing focuses on MCP wiring. Extend it with your own graph nodes. For a tutorial that builds this same weather example using Tool Builder and Agents Builder, see Deploy a custom agent with a local MCP tool.
metadata.json:
{
"agent_info": {
"name": "weather_forecaster",
"description": "Weather agent using LangGraph and weather-mcp MCP server to provide current weather information for cities worldwide",
"framework": "langgraph",
"created": "2026-03-10T00:00:00Z"
},
"function_info": {
"name": "get_weather_info",
"is_async": false,
"return_type": "str",
"parameter_names": ["query", "chat_history"],
"parameters": [
{
"name": "query",
"type": "str",
"required": false,
"default": "What's the weather like?",
"description": "Natural language weather query"
},
{
"name": "chat_history",
"type": "Optional[List[Dict[str, str]]]",
"required": false,
"default": null,
"description": "Optional conversation history in OpenAI format for context continuity"
}
]
},
"mcp_servers": ["weather-mcp"],
"mcp_config": {
"mcp_dir": "${MCP_DIR}",
"weather-mcp": {
"path_constant": "WEATHER_MCP_SERVER_PATH",
"path_value": "${MCP_DIR}/weather-mcp/server.py",
"transport": "stdio"
}
}
}
envs.json:
{
"CUSTOM_AGENT_MODEL": "claude-sonnet-4-20250514",
"CUSTOM_AGENT_BASE_URL": null,
"CUSTOM_AGENT_API_KEY": null,
"CUSTOM_AGENT_TIMEOUT": "30",
"MCP_DIR": null,
"WEATHER_MCP_SERVER_PATH": null
}
Key points:
mcp_serversdeclares the MCP tool dependency. The platform validates that this tool exists at upload time.mcp_configtells the agent where to find the MCP server at runtime.- The platform populates
MCP_DIRandWEATHER_MCP_SERVER_PATHat runtime.
custom_agents.py (MCP integration pattern):
import os
import asyncio
from typing import Optional, List, Dict
from langchain_openai import ChatOpenAI
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
def _resolve_mcp_server_path():
"""Resolve the weather MCP server path from environment."""
explicit = os.getenv("WEATHER_MCP_SERVER_PATH")
if explicit:
return explicit
mcp_dir = os.getenv("MCP_DIR", "./mcp_tools_runner")
return os.path.join(mcp_dir, "weather-mcp", "server.py")
def get_weather_info(
query: str = "What's the weather like?",
chat_history: Optional[List[Dict[str, str]]] = None,
) -> str:
"""Entry point. Uses asyncio.run() to bridge sync and async code."""
return asyncio.run(_async_get_weather(query, chat_history))
async def _async_get_weather(query, chat_history):
server_path = _resolve_mcp_server_path()
# Connect to MCP server and get tools
async with MultiServerMCPClient(
{"weather-mcp": {"command": "python", "args": [server_path], "transport": "stdio"}}
) as client:
tools = client.get_tools()
# Build LangGraph with tool-calling LLM
model = os.getenv("CUSTOM_AGENT_MODEL", "claude-sonnet-4-20250514")
llm = ChatOpenAI(
model=model,
base_url=os.getenv("CUSTOM_AGENT_BASE_URL"),
api_key=os.getenv("CUSTOM_AGENT_API_KEY", ""),
).bind_tools(tools)
# ... build and run graph with process_query -> tools -> generate_response nodes
requirements.txt:
langgraph>=0.2.0
langchain-core>=0.3.0
langchain-openai>=0.2.0
langchain>=0.3.0
langchain-mcp-adapters>=0.1.0
pydantic>=2.0.0,<3.0.0
httpx>=0.25.0
aiohttp>=3.8.0
pytest>=7.0.0
pytest-asyncio>=0.21.0
ruff>=0.1.0
mypy>=1.0.0
typing-extensions>=4.5.0
MCP tool server: weather-mcp​
A standalone MCP tool that agents can call. This example returns mock weather data.
server.py:
"""Weather MCP Server. Provides weather information for any city."""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather-mcp")
@mcp.tool()
def get_weather(city: str) -> str:
"""Get current weather information for a given city."""
if not city or not city.strip():
return "Error: Please provide a valid city name."
city = city.strip()
# In a real implementation, call a weather API here
data = {
"current_condition": [
{
"temp_C": "15",
"temp_F": "59",
"humidity": "80",
"windspeedKmph": "10",
"winddir16Point": "NW",
"weatherDesc": [{"value": "Partly cloudy"}],
}
],
"nearest_area": [
{
"areaName": [{"value": city}],
"country": [{"value": "Unknown"}],
"region": [{"value": "Unknown"}],
}
],
}
condition = data["current_condition"][0]
area = data["nearest_area"][0]
weather_desc = condition["weatherDesc"][0]["value"]
location = area["areaName"][0]["value"]
country = area["country"][0]["value"]
return (
f"Weather for {location}, {country}:\n"
f" Condition: {weather_desc}\n"
f" Temperature: {condition['temp_C']}C / {condition['temp_F']}F\n"
f" Humidity: {condition['humidity']}%\n"
f" Wind: {condition['windspeedKmph']} km/h {condition['winddir16Point']}"
)
if __name__ == "__main__":
mcp.run()
Troubleshoot common issues​
Use this section when an upload or runtime check fails.
Where to look first: Match your error in Resolve validation errors below. Then check Avoid common mistakes for framework, chat_history, and MCP issues.
Resolve validation errors​
| Error | Cause | Fix |
|---|---|---|
Missing required files: custom_agents.py, metadata.json | The ZIP does not contain required files at the expected path | Place files inside a top-level directory in the ZIP |
metadata.json validation failed: agent_info missing required field: framework | A required field in metadata.json is missing or misnamed | Verify all required fields: name, description, framework, created |
Failed to parse envs.json | Invalid JSON syntax | Validate JSON (check for trailing commas and missing quotes) |
envs.json is required but not found | The envs.json file is missing | Add an envs.json file, even if empty ({}) |
Invalid framework value | The framework name is wrong | Use one of: langgraph, crewai, or openai_agents_sdk |
Invalid created timestamp | The date format is malformed | Use ISO 8601 format: 2026-01-15T00:00:00Z |
Parameter name not found in parameter_names | A parameter's name does not appear in the parameter_names list | Add every parameter name to parameter_names |
Avoid common mistakes​
- Wrong framework name: Use
"openai_agents_sdk", not"openai", inmetadata.json. - Missing
chat_historyparameter: Every agent should acceptchat_historyfor multi-turn conversations. - No default values: Give every parameter a default so the agent runs without arguments.
- Forgetting
flush=True: Addflush=Trueto print calls. Without it, streaming output does not appear in the UI. - Standard library modules in
requirements.txt: Do not listos,json,typing, or other built-in modules. - Missing
{name}.mdfor Claude agents: Theclaudeagent type requires an additional Markdown file. - Hardcoded file paths: Use environment variables (
MCP_DIR, etc.) instead of hardcoded paths.
- Submit and view feedback for this page
- Send feedback about Enterprise h2oGPTe to cloud-feedback@h2o.ai