Unity CLI, End to End: eval in a Live Editor, and MCP for Your AI Agent

The Unity CLI startup banner in a terminal: an ASCII-art Unity logo next to a blocky UNITY wordmark, labelled 'C L I v1.0.0-beta.3', above the line 'Type unity help to see all commands.'
Click to enlarge

Unity’s command-line tool has been in public beta since April 4, and eval landed on June 4 — but it only got its formal announcement two weeks ago (forum post July 15, then a slot at the Unite Seoul keynote), which is when most people heard about it at all. I don’t think many have noticed how far it goes. The first layer looks like a Hub replacement. The last layer hands your whole editor to an AI agent as a list of callable tools. Between those two there’s a REPL that runs C# inside the editor you have open right now.

It’s four layers, each one built on the last:

LayerCommandWhat it reaches
1. Manageunity install, unity build, unity testEditors, projects, CI — no editor running
2. Driveunity command <name>The editor you already have open, via the Pipeline package
3. Reach insideunity command evalArbitrary C# against the loaded domain
4. Delegateunity mcpAll of the above, exposed to an AI agent

Everything below was run on Unity CLI 1.0.0-beta.3 — which started rolling out the day I wrote this — plus Unity 6000.3.20f1 and com.unity.pipeline 0.4.0-exp.1, against Unity’s URP 3D sample project. I’ve had it installed for two weeks, and the package is still experimental, so treat this as a field report rather than settled practice: where something is broken, I show the output that proves it. We’ll start from nothing installed.

Layer 1: the CLI itself

Step 1 — install it

The CLI is its own thing, not a Hub feature — in fact it’s the other way around, unity hub install installs the Hub. It’s a single self-contained binary, which means you can put it on a headless CI worker that has no Hub desktop app at all. One install script per platform (official docs):

# macOS or Linux
curl -fsSL https://public-cdn.cloud.unity3d.com/hub/prod/cli/install.sh | UNITY_CLI_CHANNEL=beta bash
# Windows (PowerShell)
$env:UNITY_CLI_CHANNEL = "beta"
irm https://public-cdn.cloud.unity3d.com/hub/prod/cli/install.ps1 | iex

UNITY_CLI_CHANNEL=beta is what gets you the current beta rather than stable — at the time of writing, everything in this post needs it. On Windows the binary lands in %LOCALAPPDATA%\Unity\bin\unity.exe.

Step 2 — check it works

$ unity --version
1.0.0-beta.3

If your shell says “command not found”, the binary is installed but not on your PATH — add the folder from step 1 and reopen the terminal.

Later on, the CLI updates itself — unity upgrade (with --check to look without installing, --channel stable|beta, and --rollback to restore the previous binary if a release misbehaves).

There’s also unity doctor, which prints your platform, CLI version, install path, PATH checks and every editor it can find. One caution: its output includes your Unity account name and email, so skim before pasting it into a bug report.

Step 3 — what this layer does

unity --help is a longer list than you’d expect — install, editors, modules, license, projects, templates, open, build, test, run, cache, doctor, bug. The shape of it is “everything the Hub does, minus the Hub”:

$ unity install 6000.3.20f1                    # install an editor version
$ unity open .                                 # open this project with the right editor
$ unity test . --mode EditMode                 # run tests, write an NUnit XML report
$ unity build . --target Android --execute-method Builder.PerformBuild

Note the shape of that last one: --execute-method is required, because Unity still has no built-in command-line build — the CLI spawns batch mode and calls your static method, it doesn’t invent a build pipeline for you. (Flags worth knowing: --target takes StandaloneWindows64, Android, iOS, WebGL; -o forwards an output path that your method is responsible for honouring.)

That alone is worth having on a CI box — no more hard-coded C:\Program Files\Unity\Hub\Editor\... paths in your build scripts. But this layer never talks to a running editor. That’s layer two.

Layer 2: driving the editor you already have open

Step 4 — install the Pipeline package

com.unity.pipeline is the piece that makes everything after this possible: it runs a small local server inside the editor so the CLI can reach in. It needs Unity 6.0 or later, it’s experimental, and the CLI installs it for you — no Package Manager clicking.

The order matters here, and it’s the step most likely to leave you stuck:

  1. Open your project in the Unity Editor and leave it open. This layer talks to a running editor; there has to be one.
  2. In a terminal, sign in once: unity auth login.
  3. Install the package:
$ unity pipeline install                              # into the project in the current folder
$ unity pipeline install --project-path /path/to/MyProject
  1. Switch back to the editor and wait for it to recompile with the new package. Until that finishes there’s no server to talk to, and the next command will just look broken.

Then unity pipeline list tells you the truth about every editor it can see — whether the package is present, which version, and whether the server is actually reachable:

$ unity pipeline list
Project     Path              PID    Running  Pipeline  Version      Server Port  Server Reachable
MyProject   ...\MyProject     27636  true     true      0.4.0-exp.1  7800         true

Server Reachable: true is the line that matters. If it says false, nothing in the rest of this post will work.

Step 5 — talk to the running editor

$ unity status
Port    State   Project      Version        PID
7800    ready   MyProject    6000.3.20f1    27636

unity command with no arguments lists everything that editor can do, with their parameters. The count depends on your project — it’s Unity’s built-ins plus whatever your own packages register, and on this one it came to 148:

$ unity command
Command                   Description                                     Param Count  Parameters
get_import_settings       Read an asset's import settings                 1            --path
set_player_settings       Modify PlayerSettings                           ...          ...
add_animator_parameter    Add a parameter to an AnimatorController        5            --controller, --name, --type, ...
eval                      Evaluate C# code dynamically using Roslyn       2            --code, --timeout

Then just call one — unity command get_import_settings --path Assets/Art/hero.png reads back what the importer actually produced for that asset. It executes in the live editor and answers with structured output.

Two things make this different from -batchmode -executeMethod: it runs against the editor that’s already open — the domain is loaded, assets are imported, your scene is in memory, so you skip the cold start that makes batch-mode automation feel like a build — and the output is machine-readable with --json.

If you’re wiring CI instead, there’s a headless sibling: unity run --command <name> starts the editor in batch mode, waits for the Pipeline server, runs the command with arguments after -- parsed against its schema, prints the return value and shuts the editor down — and if an editor already has that project open, it reuses it and leaves it running. The editor log (including Debug.Log) streams to stderr, --format json wraps the return value in a result envelope, and a failed command exits non-zero. Layer 2 proper is for your dev loop; this is the same verbs for CI.

Layer 3: eval — running arbitrary C# inside the editor

This is the layer I’ve reached for most since installing the thing. Every Unity developer has written this file:

public static class Scratch
{
    [MenuItem("Tools/Scratch")]
    static void Run() => Debug.Log(AssetDatabase.FindAssets("t:Texture2D").Length);
}

Save, wait for the recompile, alt-tab, click the menu item, read the Console, delete the file. Thirty seconds of ceremony to ask one question. Instead:

$ unity command eval --code 'return AssetDatabase.FindAssets("t:Texture2D").Length;'
eval  true  {"result":1605,"executionTimeMs":381,"success":true,"diagnostics":[]}

Roslyn compiles your snippet against the loaded domain and runs it on the main thread. No file, no recompile, no domain reload.

The mental model: your snippet is a method body, not a file

This one rule explains most of the compiler errors you’ll hit. What you send is dropped into a method and compiled — so statements work, declarations don’t.

Works — local variables, LINQ, lambdas, even local functions:

int Score(string s) => s.Length * 2;
var big = AssetDatabase.FindAssets("t:Texture2D").Take(5).ToArray();
return new { count = big.Length, sample = Score("ok") };

Doesn’t work — a using directive:

$ unity command eval --code 'using System.Collections.Generic; return 1;'
Error: Compilation Failed
  Identifier expected (line 1, col 44)
  'System.Collections.Generic' is a namespace but is used like a type (line 1, col 18)

In statement position using X; parses as a using statement (the IDisposable kind), so the namespace lands where a variable was expected. Same reason a class declaration fails:

$ unity command eval --code 'class Foo { public int N = 7; } return new Foo().N;'
Error: Compilation Failed
  } expected (line 0, col 9)

The workaround is fully-qualified names — and you rarely need them, because UnityEngine, UnityEditor and System.Linq are already imported:

$ unity command eval --code 'return new int[]{1,2,3}.Sum();'
eval  true  {"result":6,...}

One gotcha follows from that: Object is now ambiguous between UnityEngine.Object and System.Object. Write UnityEngine.Object.FindObjectsByType<T>().

The return value is data, not a printed string

Whatever you return gets serialized. Anonymous objects become JSON objects; arrays become JSON arrays:

$ unity command eval --code 'return new { a = 1, b = "x" };'
eval  true  {"result":{"a":1,"b":"x"},...}

So a snippet is a query that returns a typed record, not a log you have to scrape. This is exactly why layer 4 works as well as it does — an agent gets structured data back, not prose.

The corollary bites everyone once: Debug.Log does not come back. It goes to the editor Console, and the reply’s output field stays null:

$ unity command eval --code 'UnityEngine.Debug.Log("hi from eval"); return 1;'
eval  true  {"output":null,"result":1,...}

If you want to see it, return it.

Multi-line snippets: eval_file

Quoting a real script through a shell is misery, so there’s a sibling that reads from disk:

$ unity command eval_file --file /tmp/biggest-textures.cs --json
// biggest-textures.cs — the three fattest textures in the project, as structured data.
var rows = AssetDatabase.FindAssets("t:Texture2D")
    .Select(g => AssetDatabase.GUIDToAssetPath(g))
    .Where(p => p.StartsWith("Assets/"))
    .Select(p => new { path = p, kb = (int)(new System.IO.FileInfo(p).Length / 1024) })
    .OrderByDescending(r => r.kb)
    .Take(3)
    .ToArray();
return rows;

Real output:

"result": [
  { "path": "Assets/Scenes/Cockpit/Art/Props/Textures/Worm_T_LMAO.tif", "kb": 18598 },
  { "path": "Assets/Scenes/Garden/GardenScene/Lightmap-1_comp_light.exr", "kb": 16406 },
  { "path": "Assets/Scenes/Garden/GardenScene/Lightmap-0_comp_light.exr", "kb": 15875 }
]

That’s a report you’d normally build an EditorWindow for.

Your own code is already in scope

The snippet compiles against the loaded domain, so your game assembly is right there — not “reachable by reflection,” actually referenced by name:

$ unity command eval --code 'return UnityEngine.Object.FindObjectsByType<Boids>(FindObjectsSortMode.None).Length;'
eval  true  {"result":0,...}

Boids is a MonoBehaviour from that project’s Assembly-CSharp. Your static managers, a ScriptableObject singleton’s current values, editor-only types — all queryable without adding a line to the project. Reflection still helps for private state:

var asm = System.AppDomain.CurrentDomain.GetAssemblies()
    .FirstOrDefault(a => a.GetName().Name == "Assembly-CSharp");
var monos = asm == null ? 0 : asm.GetTypes().Count(t => typeof(MonoBehaviour).IsAssignableFrom(t));
return new { hasGameAssembly = asm != null, monoBehaviours = monos };
// → {"hasGameAssembly":true,"monoBehaviours":53}

Bonus, off the beginner path: asserting what your editor UI actually drew

Skip this one if you don’t write editor tooling. If you do, it’s the trick I’d least want to give up. A snippet can find an open EditorWindow and walk its rootVisualElement:

var w = Resources.FindObjectsOfTypeAll<EditorWindow>()
    .FirstOrDefault(x => x != null && x.titleContent.text.Contains("Inspector"));
if (w == null) return "not open";
var buttons = new System.Collections.Generic.List<string>();
System.Action<UnityEngine.UIElements.VisualElement> walk = null;
walk = ve => {
    if (ve is UnityEngine.UIElements.Button b) buttons.Add(b.text);
    foreach (var c in ve.Children()) walk(c);
};
walk(w.rootVisualElement);
return new { title = w.titleContent.text, width = w.position.width, buttons };

Because the window is open and painted, its layout pass has run — ve.layout and resolvedStyle are ground truth, so you can flag any element whose xMax exceeds its parent’s width. That distinction (is this row clipped because the window is narrow, or because the row refuses to shrink?) is invisible in EditMode tests, where there’s no layout pass at all and every rect is zero. I found a clipped, unclickable button in my own tool this way.

Layer 4: unity mcp — hand the whole thing to an agent

This is the layer I’d have led with if I’d understood it sooner. One command turns everything above into an MCP server:

$ unity mcp
unity mcp: MCP server started (stdio). Waiting for a client to connect.

I ran a real MCP handshake against it — initialize, then tools/list:

server:     {"name":"unity-mcp","version":"1.0.0-beta.3"}
tool count: 148
sample:     get_quality_settings, get_performance_stats, get_import_settings, import_asset,
            delete_asset, package_resolve, set_player_settings, search, set_animation_curve,
            clear_occlusion_culling, add_animator_parameter, ...

Same list as unity command, same count on this project — built-ins plus my own registered commands. And eval is one of them, with a real input schema the agent can read:

"eval": {
  "description": "Evaluate C# code dynamically using Roslyn compiler",
  "inputSchema": {
    "type": "object",
    "properties": {
      "code":    { "type": "string", "description": "C# code to evaluate" },
      "timeout": { "type": "string", "description": "Timeout in milliseconds", "default": 5000 }
    },
    "required": ["code"]
  }
}

And it genuinely executes. A tools/call of eval from the client side:

--> tools/call eval {"code":"return UnityEngine.Application.unityVersion;"}
<-- after 1662ms  {"success":true,"result":"6000.3.20f1","executionTimeMs":1351,"diagnostics":[]}

It also negotiates protocol versions properly — worth checking, because those YYYY-MM-DD strings are MCP protocol revisions, not dates, and I wanted to know which one it actually speaks. Ask for 2024-11-05 or 2025-06-18 and you get that same version back; ask for 2026-07-28, newer than it supports, and it answers 2025-11-25 — the current MCP spec revision. It’s keeping up. (That handshake works even with no editor running: the server is the CLI process. It just has no tools to list until an editor connects.)

Wiring it to a client is one command — unity mcp configure <client> supports Claude Desktop, Claude Code, Cursor, VS Code / Copilot, Codex, Windsurf, Cline, Zed, Continue, Kiro and more (unity mcp configure --list prints the whole table with the config path it would write). Pin the project if you have two editors open:

$ unity mcp configure claude-code --project-path /path/to/MyProject

Why this layer is the interesting one

An AI agent asked to optimize your Unity project, without this, does something specific and bad: it reads .meta files and guesses. I’ve watched one call 95 read/write-enabled meshes “113 textures,” treat 1.72 GB of source files as runtime memory, and recommend lowering maxTextureSize — a visual quality trade-off — as if it were free waste. Every sentence plausible, none of it measured.

Give the same agent this MCP server and the character of its answers changes:

  • It reads real imported state, not files on disk. get_import_settings returns what the importer actually produced, including the platform override that silently overrode the default.
  • eval means it isn’t limited to the tools someone thought to register. This is the part I underrated. A fixed tool list can only answer anticipated questions; with eval, an agent can compose a new query — “how many materials use this shader variant, grouped by folder” — as a C# snippet, and get a typed record back. It’s the difference between an API and a shell.
  • Answers come back as data. Anonymous-object-to-JSON means the agent gets {"count":53}, not a paragraph it has to parse and might misread.
  • Custom commands ride along for free. Any command your own tooling registers with [CliCommand] shows up in that 148 automatically, schema and all — no MCP code on your side. My perflint_scan / perflint_gate / perflint_fix appear in tools/list next to Unity’s built-ins, and I never wrote an MCP server.

A division of labor that works

Two weeks is not long enough to have a best practice, so take this as a working split rather than a conclusion — but it’s the one I keep coming back to:

Registered commands for verdicts, eval for premises. The agent calls a deterministic command to get the finding — score, grade, the ranked list — because that answer needs to be identical every time and auditable afterwards. Then it uses eval to check the premise behind a suggested fix, which is exactly the thing a fixed tool list can’t anticipate: is this mesh actually read at runtime by any of our scripts? Is that texture referenced by a scene that ships? Which of these prefabs is in a Resources folder?

That’s a real workflow and it’s why the two layers need each other. A tool that only ships fixed verbs makes the agent guess about context. An agent with only eval writes plausible C# against a project it doesn’t understand. Together you get triage: deterministic ground truth, plus the ability to verify one specific assumption before touching anything.

Keep the destructive verbs behind a confirmation. eval can delete assets. import_asset and delete_asset are in that tool list too. I let agents read freely and I keep anything that writes either dry-runnable or one click away from a human. That’s a personal policy, not a technical limit — worth deciding on purpose.

The limits that will bite you

I’d rather you hit these here than at 1 a.m.

It runs on the main thread. Your snippet freezes the editor while it executes. Fine for a query; think twice about a loop over 10,000 assets.

Both timeout knobs are decorative. On the CLI leg, --timeout is accepted and ignored:

$ time unity command eval --code 'System.Threading.Thread.Sleep(3000); return "finished anyway";' --timeout 1
eval  true  {"result":"finished anyway","executionTimeMs":3340,...}
real 0m5.5s

A 1-second timeout did not stop a 3-second snippet. The MCP leg has the same problem from the other direction — its schema advertises "default": 5000 milliseconds, and a 7-second snippet sails right past it:

--> tools/call eval {"code":"System.Threading.Thread.Sleep(7000); return \"survived 7s\";"}
<-- after 7341ms  {"success":true,"result":"survived 7s","executionTimeMs":7306}

What is enforced are fixed client-side ceilings — ~30 s for unity command, 60 s for unity mcp — which I measured in an earlier round against a deliberately slow command rather than in the runs above, so take those two numbers as mine rather than as documented behaviour. They’re client-side: the editor keeps running your code after the client gives up, so a long snippet can still be chewing on the main thread while your next call queues behind it. (This one cost me an afternoon: a timed-out command’s Thread.Sleep made the next, innocent command look like it had timed out too.)

Update, 2026-08-13 — retested on CLI 1.0.0-beta.4 + Pipeline 0.5.0-exp.1. Half of this section got fixed, and the fix ships a new trap. Same method as before: Thread.Sleep on the main thread, with a 1-second probe between runs so a leftover sleep can’t frame the next call.

  • --timeout is real now: a 10-second snippet with --timeout 5 gets cut off at 5.0 s.
  • Both ceilings moved: 30 s is now just that flag’s default (still 30 s if you pass nothing, but you can change it), and the 60 s server ceiling is gone — 40 s and 75 s evals came back complete.
  • But the default got tighter. eval’s own millisecond timeout (the default: 5000 in the schema above) went from advertised-but-ignored to enforced: anything over 5 s now dies with Main thread operation timed out after 5000ms unless you pass it explicitly. A call that quietly ran for 30 s on 0.4.0 runs for 5 s after the upgrade — my own wrapper script broke the day I updated.
  • The two knobs share a name but not units or sides: the CLI’s --timeout is client-side seconds, the command’s timeout is server-side milliseconds, split by --. Two minutes of eval is unity command eval --timeout 130 -- --code '...' --timeout 120000.
  • The MCP leg is not fixed: tools/call still dies at 60.0 s flat, and passing timeout: 150000 in the tool arguments changes nothing — the ceiling moved from the editor server into the CLI’s MCP bridge, which for an agent is the same wall.
  • New escape hatch on the CLI leg: --detach returns a job id immediately, and unity job status / unity job wait collect the result later (kept for an hour). Nothing equivalent is exposed over MCP yet.
  • (Added 2026-08-14.) The unity run --command sibling can’t answer the timeout question at all right now: it boots the editor, then fires the command while the editor is still settling after startup, and every attempt — including a 5-second sanity check against a near-empty project — died with 503 Service Unavailable: Server Busy. The error’s own advice (“poll /api/status until it reports ‘ready’”) is the right fix, but the run flow is the one that would have to follow it — it owns the editor it just booted.

The wrapper script below already carries the corrected timeout handling.

A domain reload kills the call in flight. Anything that triggers a recompile or enters Play Mode tears down the domain your snippet is running in, and the result never returns. Use eval to read state after a reload, not to drive one.

It’s arbitrary code execution against your editor, over a local port, from an experimental package. Past the one-time unity auth login, two dozen calls never prompted me for anything — whatever handshake the CLI and the editor do, they do it without me. I couldn’t find the threat model written down anywhere in the Pipeline docs or the CLI release notes, which is itself the reason to think about it before you wire this into a shared machine rather than to assume the worst.

Two smaller things. A compile failure exits with code 6 and prints Roslyn’s diagnostics with line and column, which is pleasant to script against. And the same unity command also speaks to a running development Player, not just the editor (--runtime <process name>, or --runtime-path pointing at its port file) — I haven’t tested that leg; everything in this post is the editor.

A small wrapper worth having

Two things get old fast: shell quoting, and digging the value out of the nested reply.

#!/usr/bin/env bash
# unity-eval.sh — run a C# snippet in the live editor, print just the return value.
# Needs the unity CLI on PATH, plus node (only to dig the value out of the JSON reply).
#   unity-eval.sh snippet.cs
#   echo 'return 2+3;' | unity-eval.sh -
#   unity-eval.sh -c 'return Application.unityVersion;'
set -u
TMP="${TMPDIR:-/tmp}/unity-eval-$$.cs"   # not mktemp --suffix: macOS mktemp has no --suffix
TIMEOUT="${UNITY_EVAL_TIMEOUT:-60}"      # seconds. Pipeline 0.5.0 enforces a 5 s server default unless passed
trap 'rm -f "$TMP" "$TMP.out"' EXIT
case "${1:-}" in
  -c) shift; printf '%s' "$*" > "$TMP" ;;
  -|"") cat > "$TMP" ;;
  *) cp "$1" "$TMP" ;;
esac

# Two timeouts, same name: before `--` it's the CLI's client-side wait (seconds); after `--` it's
# eval_file's own server-side limit (milliseconds). Client waits 10 s longer so the server verdict,
# which carries the compile diagnostics, arrives before the client gives up.
ARGS=(command eval_file --timeout "$((TIMEOUT + 10))" --format json)
[ -n "${UNITY_PROJECT_PATH:-}" ] && ARGS+=(--project-path "$UNITY_PROJECT_PATH")
ARGS+=(-- --file "$TMP" --timeout "$((TIMEOUT * 1000))")
unity "${ARGS[@]}" > "$TMP.out"          # stderr stays on your terminal, not in the JSON

node -e '
  const t = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
  const r = (Array.isArray(t) ? t[0] : t)?.data?.result;
  if (!r) { console.error("unexpected reply"); process.exit(1); }
  if (r.success === false) {
    console.error("C# did not run: " + (r.error || ""));
    for (const d of r.diagnostics || []) console.error("  " + (d.severity || "") + " " + (d.message || ""));
    process.exit(1);
  }
  console.log(r.result != null ? JSON.stringify(r.result, null, 2) : "(null)");
' "$TMP.out"

When not to reach for eval

eval is a general-purpose back door. It’s perfect while you’re figuring something out, and a poor place to leave the thing you figured out. Once an operation is deterministic and you’ll run it again, promote it: register it with [CliCommand] and it shows up in unity command’s listing with typed arguments — and in that MCP tool list for agents, for free. Hand-roll it in eval, then graduate it.

That arc is why PerfLint ships perflint_scan / perflint_gate / perflint_fix as registered commands rather than telling you to paste snippets — the analysis is deterministic, so it belongs behind a named verb with a stable output shape (the one-command performance audit walks through those). But those commands started as snippets, and unity-eval.sh has been open in a terminal next to me every day since.


PerfLint for Unity scans your project locally — performance, assets, and migration — with zero uploads and zero telemetry. It registers Pipeline commands, so your agent gets them over Unity’s own MCP server with no extra setup. Watch the 80-second demo.


← Back to all posts