Custom Tool Development Guide
July 14, 2026 · View on GitHub
This guide explains how to add new C# tools to Unity that can be called from the CLI.
Minimal Example
Create a C# class in AgentConnector/Editor/Tools/ (or anywhere in an Editor assembly):
using Newtonsoft.Json.Linq;
using HeraAgent;
using UnityEngine;
[HeraTool(Name = "spawn_cube", Description = "Spawns a cube at the specified position")]
public static class SpawnCubeTool
{
public class Parameters
{
[ToolParameter("X position", Required = true)]
public float X { get; set; }
[ToolParameter("Y position", Required = true)]
public float Y { get; set; }
[ToolParameter("Z position", Required = true)]
public float Z { get; set; }
[ToolParameter("Object name", Default = "Cube")]
public string Name { get; set; }
}
public static object HandleCommand(JObject @params)
{
var p = new ToolParams(@params);
var x = p.GetFloat("x");
var y = p.GetFloat("y");
var z = p.GetFloat("z");
var name = p.GetString("name", "Cube");
var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
go.name = name;
go.transform.position = new UnityEngine.Vector3(x, y, z);
return new SuccessResponse($"Spawned {name} at ({x}, {y}, {z})");
}
}
That's it. The tool is automatically discovered and callable via:
hera-agent-unity spawn_cube --params '{"x":0,"y":1,"z":0,"name":"MyCube"}'
Tool Patterns
Pattern 1: Static Class (Recommended for stateless tools)
[HeraTool(Name = "my_tool")]
public static class MyTool
{
public static object HandleCommand(JObject @params) { ... }
}
Pattern 2: Instance Class (For stateful tools)
[HeraTool(Name = "stateful_tool")]
public class StatefulTool : IHeraTool
{
private int _counter;
public object HandleCommand(JObject @params)
{
_counter++;
return new SuccessResponse($"Called {_counter} times");
}
}
The CommandRouter creates an instance via Activator.CreateInstance() for each call.
Pattern 3: Async Tool
[HeraTool(Name = "async_tool")]
public static class AsyncTool
{
public static async Task<object> HandleCommand(JObject @params)
{
await Task.Delay(1000);
return new SuccessResponse("Done after 1 second");
}
}
CommandRouter awaits Task<object> and Task results automatically.
Action handlers marked with [HeraAction] must be public static, accept exactly one JObject, and return object, Task<object>, or Task. Invalid declarations are omitted from discovery with a Unity console diagnostic.
Attributes
HeraToolAttribute ([HeraTool])
[HeraTool(
Name = "tool_name", // Command name (snake_case recommended)
Description = "What it does", // Shown in list output
Group = "Editor", // Optional grouping
Enabled = true // Can be disabled to hide from discovery
)]
ToolParameterAttribute
public class Parameters
{
[ToolParameter("Description", Required = true, Default = "default_value")]
public string MyParam { get; set; }
}
| Property | Description |
|---|---|
Description | Human-readable parameter description |
Required | Whether the parameter must be provided |
Default | Default value metadata; an explicitly empty string is preserved as "" |
EnumType | Name of enum type for enum parameters |
OutputSchema | JSON schema for the tool's output |
Parameter Access (ToolParams)
ToolParams provides typed access to the incoming JObject:
var p = new ToolParams(@params);
p.GetRequired("key") // Returns Result<string>, fails if missing
p.GetString("key", "default") // Returns string, uses default if missing
p.GetInt("key", 0) // Returns int
p.GetFloat("key", 0f) // Returns float
p.GetBool("key", false) // Returns bool
p.GetStringArray("key") // Returns string[]
Always use GetRequired() for mandatory parameters and return ErrorResponse on failure:
var result = p.GetRequired("action");
if (!result.IsSuccess)
return new ErrorResponse(result.ErrorMessage);
Response Patterns
Success with message
return new SuccessResponse("Operation completed");
Success with data
return new SuccessResponse("Found objects", new { count = 5, names = new[] { "A", "B" } });
Error
return new ErrorResponse("Something went wrong");
Raw object (auto-serialized)
return new { success = true, value = 42 };
Complete Example: Find Objects by Tag
using System.Linq;
using Newtonsoft.Json.Linq;
using UnityEngine;
using HeraAgent;
[HeraTool(
Name = "find_by_tag",
Description = "Finds all GameObjects with the specified tag"
)]
public static class FindByTagTool
{
public class Parameters
{
[ToolParameter("Tag to search for", Required = true)]
public string Tag { get; set; }
}
public static object HandleCommand(JObject @params)
{
var p = new ToolParams(@params);
var tagResult = p.GetRequired("tag");
if (!tagResult.IsSuccess)
return new ErrorResponse(tagResult.ErrorMessage);
var objects = GameObject.FindGameObjectsWithTag(tagResult.Value);
var names = objects.Select(o => o.name).ToArray();
return new SuccessResponse(
$"Found {names.Length} objects with tag '{tagResult.Value}'",
new { count = names.Length, names }
);
}
}
Call it:
hera-agent-unity find_by_tag --params '{"tag":"Enemy"}'
Testing Custom Tools
- Save the C# file in Unity
- Wait for compilation (domain reload)
- Verify discovery:
hera-agent-unity list | grep my_tool - Test execution:
hera-agent-unity my_tool --params '{"key":"value"}' - Check Unity Console for any errors
Tool Discovery Rules
- Class must have
[HeraTool]attribute - Must have a
HandleCommandmethod with signature:static object HandleCommand(JObject params)(static)object HandleCommand(JObject params)(instance)static Task<object> HandleCommand(JObject params)(async static)Task<object> HandleCommand(JObject params)(async instance)
- Tool name =
Nameattribute property, orStringCaseUtility.ToSnakeCase(className) - Duplicate names are logged as errors; the ordinal-first descriptor wins
- Discovery order is ordinal by assembly/type/tool/action name. A partially loadable assembly still contributes non-null
ReflectionTypeLoadException.Typesentries and logs the loader diagnostic.
Related Documentation
CSHARP_CONNECTOR.md— C# connector internalsCOMMANDS.md— Command referenceARCHITECTURE.md— System architecture