Skip to main content
Version: v1.7.3-7 🚧

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.

tip

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, or openai_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"
}
}
}
note

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.

Parameter without default in metadata
{
"name": "query",
"type": "str",
"required": false,
"description": "The user query"
}

Field reference​

agent_info​

FieldRequiredDescription
nameYesAgent identifier (alphanumeric characters and underscores only)
descriptionYesHuman-readable summary of the agent
frameworkYesOne of: langgraph, crewai, openai_agents_sdk
createdYesISO 8601 timestamp (for example, 2026-01-15T00:00:00Z)

function_info​

FieldRequiredDescription
nameYesEntry-point function name (must match the function in custom_agents.py)
is_asyncYesSet to true for an async function
return_typeYesReturn type: str, dict, or None
parameter_namesYesOrdered list of parameter name strings
parametersYesList of parameter objects (each requires name, type, required, description; default is optional)

Optional MCP fields​

FieldRequiredDescription
mcp_serversNoList of MCP server names the agent depends on
mcp_configNoMCP server path and transport configuration

Every parameter's name value must appear in the parameter_names list.

warning

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:

  1. Match the entry-point function name to function_info.name in metadata.json.
  2. Accept **kwargs or explicit parameters that match parameter_names.
  3. Accept a chat_history parameter as a message list in OpenAI format.
  4. Give every parameter a default value so the agent can run without arguments.
  5. Call print(..., flush=True) for progress messages so the UI can stream output.
  6. Provide a main() function with argparse for 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()
tip

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​

ValueMeaningRequirement type
nullNo default; optional for the user to provideoptional_no_default
"some_value"Default provided; the user can override itoptional_with_default
"os.environ/VAR_NAME"Required from the host environmentrequired

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.

tip

Pre-upload checklist: Run these steps in order inside your agent's directory.

  1. Install dependencies: pip install -r requirements.txt
  2. Run the agent directly: python custom_agents.py --query "test"
  3. Run tests: python -m pytest test_custom_agents.py -v
  4. Validate metadata.json: python -m json.tool metadata.json
  5. Validate envs.json: python -m json.tool envs.json
  6. Check the ZIP structure: zip -r my_agent.zip my_agent/ && unzip -l my_agent.zip
info

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 typeframework in metadata.jsonDescription
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.

tip

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.

info

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:

  1. Open the Agents page.
  2. Open the Tools tab.
  3. Click New Tool.
  4. Select Local MCP.
  5. Upload your ZIP file.

With the Python API: Use the add_custom_agent_tool method. For the full endpoint reference, see Custom Agents API.

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:

  1. Custom tools (remote_mcp or local_mcp): The platform requires an exact name match.
  2. Built-in tools: The platform uses a flexible match that ignores the .py extension.

After upload, manage tool associations in the UI (Agents > select an agent > Tools) or through the Custom Agents API.

warning

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:

  1. Open the Agents page.
  2. Open the Authentication tab.
  3. Click + New Key.

With the Python API: Use the add_agent_key method. For the full endpoint reference, see Custom Agents API.

warning

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:

  1. Open the Agents page.
  2. Select an agent.
  3. 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.

tip

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​

  1. Open the Agents page.
  2. Open the Agents tab.
  3. Click + New Agent.
  4. Select your ZIP file and enter a name.
  5. 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.

info

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_servers declares the MCP tool dependency. The platform validates that this tool exists at upload time.
  • mcp_config tells the agent where to find the MCP server at runtime.
  • The platform populates MCP_DIR and WEATHER_MCP_SERVER_PATH at 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.

tip

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​

ErrorCauseFix
Missing required files: custom_agents.py, metadata.jsonThe ZIP does not contain required files at the expected pathPlace files inside a top-level directory in the ZIP
metadata.json validation failed: agent_info missing required field: frameworkA required field in metadata.json is missing or misnamedVerify all required fields: name, description, framework, created
Failed to parse envs.jsonInvalid JSON syntaxValidate JSON (check for trailing commas and missing quotes)
envs.json is required but not foundThe envs.json file is missingAdd an envs.json file, even if empty ({})
Invalid framework valueThe framework name is wrongUse one of: langgraph, crewai, or openai_agents_sdk
Invalid created timestampThe date format is malformedUse ISO 8601 format: 2026-01-15T00:00:00Z
Parameter name not found in parameter_namesA parameter's name does not appear in the parameter_names listAdd every parameter name to parameter_names

Avoid common mistakes​

  • Wrong framework name: Use "openai_agents_sdk", not "openai", in metadata.json.
  • Missing chat_history parameter: Every agent should accept chat_history for multi-turn conversations.
  • No default values: Give every parameter a default so the agent runs without arguments.
  • Forgetting flush=True: Add flush=True to print calls. Without it, streaming output does not appear in the UI.
  • Standard library modules in requirements.txt: Do not list os, json, typing, or other built-in modules.
  • Missing {name}.md for Claude agents: The claude agent type requires an additional Markdown file.
  • Hardcoded file paths: Use environment variables (MCP_DIR, etc.) instead of hardcoded paths.

Feedback