# Custom RQL type adapters — tailor the agent to your project

An RQL type adapter teaches the agent how one of your project's types should be read, shown, written, compared and called. You write it in C# in an Editor assembly, register it with an IRqlModule, and from then on rql-query, rql-mutate, rql-schema and every tool parameter of that type understand it. This guide covers when you need an adapter, how to write, register and test one in Unity, and a complete minimal example.

_Last updated: 2026-10-17_

## What a type adapter is

RQL reflects your types automatically: public fields, readable properties and, in Unity, private [SerializeField] fields are members the agent can query and set. An adapter changes that reflection model and the value semantics for one type family — an exact type, a base class (it applies to subclasses), an interface, or an open generic such as List<>. It never changes the language or the plan format, so queries stay portable.

Every applicable adapter takes part, most specific first. Members are configured from the least specific adapter to the most specific, so an adapter for your component refines what Unity-MCP's Component and Object adapters already set up. Each capability is optional: an adapter that does not implement one defers to the next adapter, then to RQL's built-in behaviour.

## When you need one

- A small struct the agent should write as a literal — a range, a grid coordinate, a fixed-point number: compact "(a, b)" input and output with approximate equality (NumericTupleAdapter<T>).
- An opaque type the reflector cannot see well — a handle, an id wrapper, a value stored in a native object: Encode for output, TryCoerce for input, Equal / Order for where and sort.
- A getter with side effects (it allocates, instantiates or loads): ExplicitOnly keeps it reachable by path but never reads it implicitly.
- Back-pointers and shared assets that generic recurse should not follow: Reference. Noise the agent should never see: Hide or Only.
- Domain concepts that are not plain members — a computed value, a rate stored as an interval, a dictionary behind an API: virtual members (Add) with optional validation, and virtual maps (Map).
- Domain operations the agent should be able to call: Method / Function, invoked with the call stage.

You do not need an adapter for ordinary public or [SerializeField] members of your MonoBehaviours and ScriptableObjects, or for Unity's vectors, colors, quaternions, materials and particle-system modules — Unity-MCP already handles those.

## Capabilities

| Capability | API (RqlTypeAdapter / MemberTable) | Affects |
| --- | --- | --- |
| Members | Members(MemberTable<T>): Add, Hide, Only, IncludeFields, ReadOnly, Reference, ExplicitOnly, Describe | paths, where, select, set, describe, recurse |
| Methods | Method, Function, Expose(name), HideMethods | call stage, schema |
| Virtual maps | Map<TValue>(name, keys, get, set?, validate?) | M["k"] reads, set M["k"] = v |
| Encode | Encode(value, EncodeContext) | output cells |
| Coerce | TryCoerce(value, target, kernel, out result, out reason) → NotHandled / Ok / Failed | set values, call arguments |
| Compare | Equal(value, other, kernel), Order(value, other, kernel) | ==, !=, <, sort, min / max |
| Identity / liveness | Id(value), Alive(value), Identity(name), Display(name) | $ref, ref("Type#id"), stale-ref |
| Write-back | CopyOnRead(names), WritesThrough() | getters that return copies, handle structs |
| Describe | Description, LiteralSchema | rql-schema / describe |
| Numeric structs | NumericTupleAdapter<T>: Width, Part, Make, Expected, optional ParseExtra | "(x, y)" / [x, y] / {x, y} in, "(x, y)" out |

## How to write one in Unity

1. Put adapters in Editor code. Unity-MCP ships the RQL assemblies for the Editor only, so place adapters in an Editor-only asmdef that references com.IvanMurzak.Unity.MCP.Editor (with the UNITY_MCP_READY define constraint, as Unity-MCP's own extensions do). Your runtime types need no RQL reference at all — that is why adapters exist.
2. Subclass RqlTypeAdapter<T> and override only what you need. For vector-like structs, subclass NumericTupleAdapter<T>.
3. Write an IRqlModule that adds your adapters (and optional sources, functions and a guide). Unity-MCP discovers every public IRqlModule with a parameterless constructor through TypeCache when it builds its RQL engine — there is no registration call.
4. Recompile. Check the result with rql-schema("YourType"), then try it with rql-query and rql-mutate (dry_run first).

## Worked example

A spawner component with a custom FloatRange struct. Without an adapter the agent would have to read and write FloatRange as an object with two fields. With the adapters below, it writes "(1, 3)" or [1, 3], compares with ==, sees a computed aliveCount, gets a validated spawnsPerMinute next to the raw interval, and can call SpawnNow().

### 1. Runtime code (unchanged)

```csharp
// Assets/Scripts/EnemySpawner.cs — ordinary runtime code, no RQL reference.
using System;
using UnityEngine;

[Serializable]
public struct FloatRange
{
    public float min;
    public float max;
}

public class EnemySpawner : MonoBehaviour
{
    public FloatRange spawnDelay = new FloatRange { min = 1, max = 3 };
    public float interval = 2f;
    [SerializeField] private string debugNotes = "";

    public int CountAlive() => transform.childCount;

    public void SpawnNow() { /* instantiate one enemy under this transform */ }
}
```

### 2. An Editor-only assembly

```json
{
    "name": "MyGame.Editor.Rql",
    "references": [
        "com.IvanMurzak.Unity.MCP.Editor"
    ],
    "includePlatforms": [
        "Editor"
    ],
    "autoReferenced": true,
    "defineConstraints": [
        "UNITY_MCP_READY"
    ]
}
```

### 3. The adapters (Editor)

```csharp
// Assets/Editor/Rql/MyGameRqlAdapters.cs — Editor-only (Unity-MCP ships RQL for the Editor).
#nullable enable
using Rql.Execution;

namespace MyGame.Editor.Rql
{
    // FloatRange in and out as "(min, max)" or [min, max]; {min: 1, max: 3} also works.
    public sealed class FloatRangeAdapter : NumericTupleAdapter<FloatRange>
    {
        public override string Description => "FloatRange: write as \"(min, max)\" or [min, max]";
        protected override string Expected => "(min, max) or [min, max]";
        protected override int Width => 2;
        protected override float Part(FloatRange v, int i) => i == 0 ? v.min : v.max;
        protected override FloatRange? Make(float[] f) =>
            f.Length == 2 ? new FloatRange { min = f[0], max = f[1] } : (FloatRange?)null;
    }

    // EnemySpawner: hide a noisy field, add computed members and a callable method.
    public sealed class EnemySpawnerAdapter : RqlTypeAdapter<EnemySpawner>
    {
        public override string Description =>
            "EnemySpawner: spawnsPerMinute (writable, > 0), aliveCount (read-only); call SpawnNow()";

        protected override void Members(MemberTable<EnemySpawner> m) =>
            m.Hide("debugNotes")
             .Add("aliveCount", s => s.CountAlive(), description: "enemies currently alive under this spawner")
             .Add("spawnsPerMinute",
                  s => 60f / s.interval,
                  (s, v) => s.interval = 60f / v,
                  validate: (s, v) => v > 0f ? null : "spawnsPerMinute must be greater than 0",
                  description: "spawn rate; writes interval")
             .Method("SpawnNow", s => s.SpawnNow(), "spawn one enemy now");
    }
}
```

### 4. Register them with a module

```csharp
// Assets/Editor/Rql/MyGameRqlModule.cs
#nullable enable
using Rql.Execution;

namespace MyGame.Editor.Rql
{
    // Unity-MCP finds every public IRqlModule with a parameterless constructor (TypeCache),
    // so declaring the class is the registration.
    public sealed class MyGameRqlModule : IRqlModule
    {
        public int Order => 200; // core is configured first; Unity-MCP's own extensions use 100

        public void Configure(RqlModuleContext ctx) =>
            ctx.AddAdapter(new FloatRangeAdapter())
               .AddAdapter(new EnemySpawnerAdapter())
               .AddGuide("My game", "EnemySpawner: spawnDelay is a FloatRange, write \"(min, max)\". " +
                                    "spawnsPerMinute must be > 0. call SpawnNow() spawns one enemy.");
    }
}
```

### 5. What the agent can now do

```text
objects | get components | where $ is EnemySpawner | select gameObject.name spawnDelay spawnsPerMinute aliveCount
objects | get components | where $ is EnemySpawner | where spawnDelay == "(1, 3)" | count
objects | get components | where $ is EnemySpawner | set spawnDelay = [0.5, 2], spawnsPerMinute = 30
objects | get components | where $ is EnemySpawner | call SpawnNow()
```

Validation runs before any write: set spawnsPerMinute = 0 fails with coercion-failed and the reason "spawnsPerMinute must be greater than 0", and nothing changes. Each mutation is one Undo step in the Editor.

## How to test it

- rql-schema("EnemySpawner") lists aliveCount, spawnsPerMinute and SpawnNow() with their descriptions; debugNotes no longer appears. rql-schema("FloatRange") shows the literal hint.
- rql-mutate with dry_run: true returns the receipt ($ref, member, old, new) without writing.
- From a terminal: unity-mcp-cli run-tool rql-query --input '{"query": "objects | get components | where $ is EnemySpawner | select spawnDelay"}'.
- In an EditMode test (a test asmdef that references com.IvanMurzak.Unity.MCP.Editor), run queries through RqlUnity.Engine — the same engine the tools use. RqlUnity.Reset() rebuilds it so modules are discovered again.

```csharp
// Assets/Editor/Tests/EnemySpawnerRqlTests.cs — EditMode test assembly (Editor only)
using com.IvanMurzak.Unity.MCP.Editor.Rql;
using NUnit.Framework;
using Rql;
using Rql.Execution;
using UnityEditor.SceneManagement;
using UnityEngine;

public class EnemySpawnerRqlTests
{
    const string S = "objects | get components | where $ is EnemySpawner";

    [SetUp]
    public void SetUp()
    {
        EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
        RqlUnity.Reset(); // fresh engine: modules are discovered again
        new GameObject("Spawner").AddComponent<EnemySpawner>();
    }

    static QueryResult Q(string rql) => RqlUnity.Engine.Execute(rql);
    static QueryResult M(string rql) =>
        RqlUnity.Engine.Execute(rql, null, new QueryOptions { AllowMutation = true });
    static string Table(QueryResult r) => AgentFormat.Render(r, OutputFormat.Table);

    [Test]
    public void FloatRangeReadsAsATuple()
    {
        var r = Q(S + " | select spawnDelay");
        Assert.IsTrue(r.Ok, Table(r));
        Assert.AreEqual("(1, 3)", r.Rows![0][0]!.ToJsonString().Trim('"'));
    }

    [Test]
    public void ListLiteralWritesTheStruct()
    {
        var r = M(S + " | set spawnDelay = [0.5, 2]");
        Assert.IsTrue(r.Ok, Table(r));
        Assert.AreEqual(0.5f, Object.FindAnyObjectByType<EnemySpawner>().spawnDelay.min);
    }

    [Test]
    public void ValidationRejectsBeforeAnyWrite()
    {
        var r = M(S + " | set spawnsPerMinute = 0");
        Assert.IsFalse(r.Ok, Table(r));
        Assert.AreEqual("coercion-failed", r.Diagnostics[0].Code, Table(r));
    }
}
```

## Rules that keep adapters safe

- Validate in validate (or TryCoerce), not in the setter: validation runs while the mutation is planned, before any write. A setter that throws triggers the rollback.
- Make virtual setters round-trip their getter. The rollback writes the old value back through the setter.
- Virtual setters on value types are rejected, because they would write to a copy. Put them on the owning class.
- Configuring a member that does not exist (including Hide / Only with a wrong name) fails the query with internal-error that names the member.
- Adapters never call each other. For nested values, ask the kernel (kernel.TryCoerce(value, typeof(Color), …), kernel.Equal) so whichever adapter handles that type is used.
- Never expose a getter with side effects as a normal member. Use ExplicitOnly or Hide.
- engine.Types.Explain(type) prints the resolved adapter order when two adapters disagree.

## Tailoring the agent to your project

- RQL modules and adapters teach the data layer your types. Since every Unity tool parameter is an RQL value, an adapter also changes how your custom tools receive and return those types.
- AddGuide puts project-specific instructions and example queries into the agent's unity-rql skill. AddSource and AddFunction give it entry points such as a mygame source.
- Custom MCP tools ([AiTool] methods) remain the right choice for multi-step workflows that are more than reading and setting members. The AI Tools Template (https://github.com/IvanMurzak/Unity-MCP/tree/main/templates/extension) scaffolds a package with an RQL module, an example adapter and tests. See the tools catalog (https://ai-game.dev/docs/tools) and extensions (https://ai-game.dev/docs/extensions).

---

Source (HTML): https://ai-game.dev/docs/rql/custom-adapters
