gda
SkillWeb & browsingDrive the Godot game engine from the command line with `gda`, an agent-first CLI with structured JSON output. Use when building, editing, or inspecting a Godot project — create/edit scenes, nodes, GDScript, resources, shaders, themes; run static analysis; export builds (all headless, no editor) — or to control a running game live (runtime scene tree, input simulation, screenshots, performance, runtime logs/errors) via the gda daemon. Use when the user mentions gda, Godot automation, headless Godot, or asks an agent to make/modify a Godot game. Always pass `--json` and read the single result object; run `gda --help` or `gda schema` to discover the full command surface.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the gda skill
What this skill tells your AI
The instructions your AI receives, as published by aigengame/godot-agent in src/gda/skill/SKILL.md and read by ahel’s review.
gda is an agent-first CLI for the Godot engine: every operation is one command
with structured JSON output, so you build and inspect a game without opening the
editor. Two kinds of operation: headless (a one-shot godot --headless per
call — scenes, scripts, exports) and live (against a running game via the
gda-daemon).
Grammar
gda <group> <command> [options] --json
Exactly one JSON result is printed to stdout; all engine noise (warnings,
progress, the engine banner) goes to stderr. Read stdout, ignore stderr unless
debugging. info / version / help / schema / skill are top-level meta
commands (no group).
Setup
- Engine — set
GDA_GODOTto your Godot binary (or pass--godot PATH). - Project — resolved by
--project DIR→$GDA_PROJECT→ the current directory; the directory must containproject.godot. Resolving a project runs that project's autoloads at engine startup. - Projectless — file-path-only operations run with no project; they resolve
filesystem paths but not
res://. Meta commands never inherit a project ($GDA_PROJECT/cwd is ignored, so an invalid one cannot break them);gda infostill accepts an explicit--project, validated as anywhere else, while the other meta commands take none. - Provenance —
gda --version --jsonreports whichgdais running: its version, the executable and interpreter paths,package_path(the directory the running code was imported from),install_kind(wheel,editable, orunknownwhen the install metadata cannot be read — gda will not guess), and, for an editable install, thesourcecheckout with its Gitrevisionanddirtyflag. No Godot is spawned, so it also works where an engine spawn fails. Run it first in a long session and keep the output: an editable install can change revision under you mid-run, so this is what ties your results to the code that produced them. Baregda --versionstays one human-readable line. - User data (headless runs) — each headless run's engine log goes to a private
temporary file, so a read-only Godot application-data directory is not fatal and
concurrent runs never contend. If a script needs a writable
user://, passgda --user-data-root DIR <group> <command>(or set$GDA_USER_DATA_ROOT) to place the log anduser://underDIR; a targetgdacannot create is refused asuser_data_unwritablebefore the engine starts. Two limits: Godot reads the export templates from that same directory, so arelease/debugexport rununder it reports none installed unless you put templates there —--mode packneeds no templates and works normally; and Engine sessions are unaffected either way — the daemon owns their log.
Structured output & errors
Always pass --json. It selects the channel for BOTH outcomes: without it a
failure prints as human-readable lines instead of the envelope. A success is the
operation's result object. A failure is
{"error": {"category": "...", "code": "...", "message": "..."}}
Branch on the stable category/code and the exit code, never on prose:
| Exit | Meaning |
|---|---|
0 | success |
2 | gda could not resolve what you asked for: unknown_command, unknown_option |
127 | environment unusable: binary_not_found, user_data_unwritable, live_unsupported_platform, live_windowed_unavailable, live_windowed_permission_denied, harness_install_permission_denied |
124 | engine timed out: launch_timeout — gda ended a run that had not returned (read it as below) |
3 | engine version too old |
4 | operation-reported failure |
5 | could not parse the engine's output |
6 | live operation failed (e.g. daemon_not_running) |
Reading a 124. The environment category describes how the run ENDED, not the
host. Read the captured partial output in diagnostics for how far the run got, then
raise the ceiling with --timeout where the command exposes one; where it does not the
ceiling is gda's own, so reduce the work or give the machine more headroom. Suspect the
binary or the machine only when the capture shows the engine never started. Any engine
error inside that capture is advisory — the verdict is the timeout.
A command or option gda does not recognize is reported the same way — an
unknown_command / unknown_option envelope at exit 2 — and when gda recognizes
the mistake the envelope carries a hint naming the invocation to run instead
({"error": {"code": "unknown_command", "hint": "gda scene get", …}}). Re-issue the
hint; when there is none, gda schema lists every command and
gda help <command> describes one.
A failure that computed evidence also carries it as DATA, under the envelope's
optional evidence key — omitted, never null, on the failures that computed none.
Read it instead of parsing the message: elapsed_seconds / termination_phase
(launched = the engine wrote nothing at all, so suspect the binary or the host;
output_seen = it was alive and did not finish, so raise the ceiling;
aborted_on_error) on a run gda ended, plus timeout_seconds — the reached
ceiling — on the timeout verdict only (an abort stopped short of its ceiling, so
it omits the field; your own --timeout stays in the message), exit_status
(the CHILD's, not gda's exit code) on script run --strict's script_failed,
and script_errors on the script run failures that parse the run's stderr —
the never-ran verdicts, --strict's script_failed, and both runs gda ended. Under a launch_timeout those errors
stay ADVISORY — the verdict is the timeout, so branch on code and read
evidence for the cause.
Each script_errors entry is a {kind, message, path, line} record — the same
four keys, always present, that a successful script run reports as
diagnostics; path and line are null where the engine named neither, which
is the normal case for a load error. The list itself has three states worth
telling apart: absent means this failure's channel does not parse stderr at all,
so read diagnostics; [] means it parsed and recognized nothing, which is
itself a finding; a non-empty list is what it recognized.
Some commands carry a verdict inside a successful result. For
gda script validate --json, read the result's valid field: it is the AGGREGATE
verdict over every script the call validated, false as soon as one of them fails.
Each script's own verdict is an entry under scripts (path, valid,
error_string, diagnostics). A script that does not compile exits 0 with no
top-level error, so do not treat exit 0 or the absence of an Error envelope as a
pass for this command. Validate the whole set you changed in ONE call —
gda script validate a.gd b.gd c.gd --json uses a single engine launch, and
--all validates every script in the project. Check the result's project_root
before you act on a valid=false: it names the project the scripts were compiled
against, and a verdict full of missing-res:// errors (plus the type errors derived
from them) usually means the wrong project, not a broken script — null means no
project was resolved at all. Pass --project for the project that owns the files
and re-read the verdict. gda refuses up front, with target_outside_project, when
the resolved project plainly does not own a path — outside its tree, or claimed by
a nested project.godot, and with no project resolved too (script run and
resource import refuse the same way). It names the owner it found
(evidence.owning_project) but never switches to it, and the message states the
whole re-issue: --project <owner> AND the target respelled relative to that
owner — a relative path anchors at the project, so your original spelling would
not be found under the new one. --all needs no such check: every project-wide listing
(script list, scene list, script validate --all, project analysis) walks the
res:// tree with the engine's own skip rule, declining the engine cache, a directory
holding a nested project.godot, and one holding a .gdignore (hidden and dot-prefixed
directories gda still enumerates, unlike the engine). So --all reports only
what the resolved project owns; to work on a nested project's files, run with
--project <that project>.
Discovery
gda --help— every group.gda <group> --help— a group's commands.gda help <group> <command>— the same help from the command form; with--jsonit comes back as{command, text}.gda <group> <command> --schema— one command's input/output/error JSON Schema (no Godot spawned), plusargv: how each parameter is written on a command line (kindpositional or option, itspositionor--optionspelling, whether it isrequired, a valuelessflag,multiple— repeat it per value — or ajson_value— one token carrying the value's JSON). Build the command line fromargv;input_propertylinks each binding to the input property it fills.gda schema— the whole surface as one JSON manifest,argvincluded.gda version --json— whichgdais installed and where from;gda info— the engine's version.
--json placement. Every parser takes it: gda --json <group> <command>,
gda <group> --json <command> and gda <group> <command> --json mean the same thing — a --json
written before the command applies to the command it invokes — so any spelling works, as do several
at once. gda schema --json is accepted too, and idempotent: the manifest is already JSON. Two
limits: the --help FLAG always renders TEXT (gda --json --help returns the same help, never
JSON — use gda help <command> --json for a structured payload), and the flag is not a command, so
gda <group> --json and a bare gda --json with no command are both the usage error
Missing command. (exit 2).
Headless commands (Godot 4.4+, all platforms)
| Group | Commands |
|---|---|
scene | create, get, list, get-exports, delete, validate, preflight (.tscn files; validate is the STATIC verdict get does not give — a scene loads fine with its script and texture missing, so check dependencies resolve and attached scripts compile before trusting it; invalid exits 0 with valid: false plus one problem per problem file — COMPOSED over the sub-scenes it references — normally the ones it instances, but a reference is followed when its path ends in .tscn/.scn OR its [ext_resource] line declares type="PackedScene", a union because Godot loads every [ext_resource] whatever it is called while ResourceSaver will put a PackedScene in a plain .res (one saved under a non-scene extension AND declared as something else stays outside) — so a parent whose child is broken is invalid too and every problem carries scene, the file it was found in, in the same canonical spelling as the result's path (read its path/nodes against THAT file); three kinds of edge are reported instead of followed — cyclic_instance for a cycle, unreadable_sub_scene for a scene that loads but carries no [gd_scene] text to walk (a binary .scn, or a PackedScene in a .res), and instance_depth_exceeded for a scene no route reaches within 16 levels of sub-scenes; the last two say that subtree is UNCHECKED, not sound (validate it directly, or re-save it as .tscn, for its own verdict), and the depth bound is on the shortest route so the verdict does not depend on declaration order, and it covers gda's walk only, not the engine's own load of the chain; staged: unresolved dependencies suppress the script compile/binding pass, so repair them and rerun for the rest. preflight is the DYNAMIC one: it boots the scene headless, waits for _ready, and reports status (ready/not_ready/timeout) plus the script errors seen during startup — read started; a timeout verdict also carries elapsed_seconds and timeout_seconds (the --timeout it reached), the same evidence pair the launch_timeout envelope gives elsewhere — it names the consumed ceiling, not the cause: a stuck scene and a healthy one whose --frames window outruns the same ceiling read alike, so pick a larger --timeout or fewer --frames from what the scene should do, and rerun — both keys are on that verdict only and omitted from every other. Passing validate is not "it works": check both) |
node | add, get, list, set, remove, duplicate, move, connect-signal, disconnect-signal (nodes within a scene) |
script | create, get, list, set, delete, attach, validate, run (.gd files; validate takes SEVERAL paths at once — one engine launch for the whole batch, one aggregate valid plus a per-file entry under scripts — or --all for every script in the project; run executes a project script one-shot (address it project-relative or as res:// — the two portable forms, which script validate takes too; run alone refuses absolute paths) and passes its exit_status/stdout/stderr through — stdout above 64 KiB is truncated to its leading bytes with the COMPLETE stream spilled to the file named in stdout_file (stdout_bytes/stdout_truncated disclose it; a spill gda cannot write is the typed stdout_spill_failed, never an unbounded result), and a non-zero quit() is still success, so read exit_status, or pass --strict to get a script_failed failure (exit 4) whose diagnostics carries the script's own stdout and stderr; a script that never ran — missing, or a failed parse/compile — always fails; --timeout <s> sets the ceiling (default 120) and a run that reaches it fails with launch_timeout carrying the captured partial output, the elapsed seconds and a termination phase; add --completion-marker <line> naming a line your script prints when its work is done — a caller-declared liveness contract, not a death detector: gda ends the run once it observes a recognized error attributable to the entry script, no marker line yet, and then silence on both streams — script_aborted (exit 4) with the captured error, in seconds rather than at the ceiling; declaring the marker asserts the script keeps printing until that line, so have it print progress during quiet stretches longer than ~3s, or omit the marker) |
project | info, get, set, list, add-autoload, remove-autoload, add-input-action, remove-input-action, find-references, dependencies, find-unused-resources, statistics |
resource | create, get, set, delete, uid, import (.tres files and project assets; import ensures importable assets — PNGs and other files the engine imports — are in the project cache: clean-worktree loading; a script needs no import and reports not_importable) |
export | list, get, run (export a preset by name; --mode release/debug/pack) |
shader | create, get, set (.gdshader files) |
theme | create (a loadable .tres Theme) |
Every headless reply carries its floats at full binary64 precision, so a value read back through node get, scene get-exports, project get/project list, resource get, or the echo of a set is the exact number the project holds — 1e-300 reads back as 1e-300, not 0.0; the one residual belongs to the ENGINE's writer — a negative zero reads back as 0.0 (#771). The --value string you send IN is coerced by the engine's own parser, and gda refuses what that parser would destroy: a literal it reads as 0.0 when you did not write zero, or as NaN at all, fails with uncoercible_value (exit 4, target untouched) instead of writing a number you never sent — 2.2250738585072014e-308 and 5e-324, and also 0.000000000000000001, whose 18 leading zeros fill the parser's whole mantissa window (1e-18 is exact). The rule follows the LITERAL, not the property type, so a number inside a Dictionary or Array --value is refused the same way and names the offending literal: --value '{"a": 1e-320}' fails, --value '{"a": 1e-18}' stores exactly. Only real JSON numbers are read — a numeric-looking STRING value ({"a": "1e-320"}) and a numeric-looking KEY ({"1e-320": 1.0}) are stored unchanged (#805). So spell a small or many-digit value in SCIENTIFIC notation carrying only the digits it needs: that also avoids the low-digit loss the parser inflicts on a full-precision literal between 1e-4 and 1e-2, which is disclosed rather than refused (#772). Read the set echo when the exact bits matter.
Live operations (via the daemon; Godot 4.6+, macOS/Linux)
Prerequisites: run gda daemon start first (optionally --scene <res://...> to boot a
specific scene instead of the project's main scene); the engine session launches lazily on
the first operation that requires one. To establish the session deterministically, run
gda daemon wait-ready (--timeout budgets daemon waits and new-work decisions;
a synchronous launch call can delay expiry observation; idempotent while the session is
alive) — success means live reads serve. This matters for the read-only diagnostics: diag errors /
logger tail never launch a session themselves, so right after daemon start they report
engine_session_not_running by design — expected, not a defect; run wait-ready first.
A live_timeout discards the session (its late reply can no longer be attributed), so the
next operation starts a fresh game and the runtime state you had set is gone. Most often
it means the game stopped returning to its main loop — look for a blocking loop or wait in
game code. But the 30s bound is a wall clock while a multi-frame window (--frames,
--await-frames) waits that many ENGINE frames, so on a slow-ticking game a window op
outruns it with the loop running normally: ask for fewer frames when gda logger tail
shows the log kept advancing. A paused SceneTree is NOT a cause; see "paused vs
suspended" below.
screen capture needs a windowed session
(gda daemon start --windowed).
A windowed session needs the host's real desktop session — an on-console GUI login on
macOS, $DISPLAY / $WAYLAND_DISPLAY on Linux. Over SSH, on a headless CI box, or from
a sandbox that blocks the window server, daemon start --windowed refuses before
spawning Godot. Branch on the code, not the sentence:
live_windowed_unavailable— nothing refused the probe and no session is reachable, so this host cannot show a window. Skip the rendered check; headless live ops (game,perf,input,diag,logger) still work.live_windowed_permission_denied— this process is not allowed to even look up the window server (e.g. a sandbox). It does NOT mean the host has one: macOS refuses the lookup before resolving it, so a broadly-confined process is refused either way. Re-run outside the restriction to find out; do not record the machine as display-less on this code alone.
A refusal from gda daemon start --windowed carries error.probe {name, platform}
naming the OS call that decided — including when the refusal is relayed from an
already-running daemon's lazy Engine-session launch; only the outer
{stdout, stderr, exit_code} transport shape is probe-less.
| Group | Commands |
|---|---|
daemon | start, wait-ready, stop, status, install, uninstall (lifecycle; start installs the in-game harness itself, so install is only for doing that step deliberately — e.g. to review or commit the project.godot change — and uninstall reverses it; wait-ready establishes the lazily-launched engine session, with --timeout shared by its waits and new-work decisions, so a first diag errors serves instead of reporting engine_session_not_running; status reports the last successfully established engine session's session_id — the identity a screen capture receipt correlates with, minted anew per established session and retained across a failed replacement launch) |
game | tree, get, rect, set, call (the running game's runtime scene graph; get --texture-digest opts a read into content digests for path-less Texture2D values. call --method NAME [--args JSON] invokes a method named by the GDA_CALLABLE declaration resolved from the node's attached script along its base chain — use it for a debug/state contract exposed as a method rather than a property. gda calls nothing undeclared, so an undeclared-but-present method is live_method_not_allowlisted and its message names the declared set; a missing one is live_unknown_method, and arguments the declared parameters cannot take (wrong count, a type the engine would not convert, a typed Array[int] parameter) are live_invalid_call_args, refused before the call. The live parser materializes every JSON number as float. NaN/Infinity are refused; RFC JSON excludes them, although some in-memory schema validators accept them as numbers. Finite floats do not inherit the integer bound, but a float whose wire literal Godot's parser reads as 0.0 is refused too — DBL_MIN, any subnormal, and many-digit values such as 1.2345678901234567e-300, none of which any decimal spelling can deliver; a float it does read arrives changed in its low-order bits — 1 ULP at ordinary magnitudes, and tens of doubles for a full-precision literal between 1e-4 and 1e-2, where the parser truncates past 18 mantissa digits (#752). JSON integer values beyond ±(2^53−1) are refused CLI-side because the wire can change them. Standard JSON Schema cannot distinguish an exponent-form float from the equal integer, so the params model enforces the integer-token limit at execution. LIMIT: gda CANNOT verify a declared method has no side effects — the constant records the project's own read-only assertion, and what gda guarantees is only that no undeclared method is called. GDScript forbids redeclaring a base class's constant, so an opted-in inheritance chain has at most one declaration owner; a base owner covers its subclasses and need not define every method it names) |
diag | errors (structured runtime errors with callstacks; survive a crash) |
logger | tail (the running game's structured log stream; --raw for verbatim lines, --level <min> to filter by severity, --limit N) |
perf | monitors, monitor (counters: a one-frame snapshot, or with --frames a bounded window with statistics and optional --budget verdicts / a per-node timeline) |
input | key, mouse-click, mouse-move, action, tap, sequence |
screen | capture, frames (viewport PNGs; needs --windowed. frames --summary returns the compact aggregate — directory, filename pattern, frame size, total bytes — instead of the per-frame list, so a large capture's envelope stays small; every frame is still written. capture --await-node/--await-property/--await-value [--await-frames] [--await-events] is the predicate-gated form: it fires on the first frame boundary where the property equals the value, optionally injecting input inside the same window — use it for short transients instead of a separate input + capture. The observed property and the pixels belong to the same COMPLETED frame; a value overwritten before its frame completes is never observable, an injected event's effect is observable from the next boundary, and a declared event that fails makes the capture that typed failure. Every capture result carries a receipt binding the image to its capture event — session_id correlating with daemon status, the LAUNCHED scene's path and header uid (uid null for gda-authored scenes), the engine frame, the gated capture's observed echo, and the written file's SHA-256 — so a plain capture needs no local hashing, and a gated capture's complete evidence is the receipt plus the sibling predicate report) |
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 38
- Forks
- 6
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
gda- Source
- github.com/aigengame/godot-agent