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

[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; }
}
PropertyDescription
DescriptionHuman-readable parameter description
RequiredWhether the parameter must be provided
DefaultDefault value metadata; an explicitly empty string is preserved as ""
EnumTypeName of enum type for enum parameters
OutputSchemaJSON 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

  1. Save the C# file in Unity
  2. Wait for compilation (domain reload)
  3. Verify discovery: hera-agent-unity list | grep my_tool
  4. Test execution: hera-agent-unity my_tool --params '{"key":"value"}'
  5. Check Unity Console for any errors

Tool Discovery Rules

  1. Class must have [HeraTool] attribute
  2. Must have a HandleCommand method 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)
  3. Tool name = Name attribute property, or StringCaseUtility.ToSnakeCase(className)
  4. Duplicate names are logged as errors; the ordinal-first descriptor wins
  5. Discovery order is ordinal by assembly/type/tool/action name. A partially loadable assembly still contributes non-null ReflectionTypeLoadException.Types entries and logs the loader diagnostic.