Unity CLI, End to End: eval in a Live Editor, and MCP for Your AI Agent
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:
| Layer | Command | What it reaches |
|---|---|---|
| 1. Manage | unity install, unity build, unity test | Editors, projects, CI — no editor running |
| 2. Drive | unity command <name> | The editor you already have open, via the Pipeline package |
| 3. Reach inside | unity command eval | Arbitrary C# against the loaded domain |
| 4. Delegate | unity mcp | All 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:
- Open your project in the Unity Editor and leave it open. This layer talks to a running editor; there has to be one.
- In a terminal, sign in once:
unity auth login. - Install the package:
$ unity pipeline install # into the project in the current folder
$ unity pipeline install --project-path /path/to/MyProject
- 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_settingsreturns what the importer actually produced, including the platform override that silently overrode the default. evalmeans 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; witheval, 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. Myperflint_scan/perflint_gate/perflint_fixappear intools/listnext 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.Sleepon the main thread, with a 1-second probe between runs so a leftover sleep can’t frame the next call.
--timeoutis real now: a 10-second snippet with--timeout 5gets 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(thedefault: 5000in the schema above) went from advertised-but-ignored to enforced: anything over 5 s now dies withMain thread operation timed out after 5000msunless 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
--timeoutis client-side seconds, the command’stimeoutis server-side milliseconds, split by--. Two minutes of eval isunity command eval --timeout 130 -- --code '...' --timeout 120000.- The MCP leg is not fixed:
tools/callstill dies at 60.0 s flat, and passingtimeout: 150000in 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:
--detachreturns a job id immediately, andunity job status/unity job waitcollect the result later (kept for an hour). Nothing equivalent is exposed over MCP yet.- (Added 2026-08-14.) The
unity run --commandsibling 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 with503 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.