Integrating @amd-gaia/gaia
@amd-gaia/gaia delivers two binaries and owns their process lifecycle: the
frozen agent sidecar (gaia-agent) and the Go terminal UI (gaia-tui).
It ships no agent logic of its own, and it builds neither binary at install
time — both are published artifacts it downloads and verifies. The terminal UI
is the published terminal-hub component, the same binary a full GAIA install
runs as gaia tui, so its behaviour cannot differ from that one. Everything runs on the local machine against
a local model server — nothing you type or index leaves it.
Two ways in:
npx @amd-gaia/gaia— fetch, verify, launch the terminal UI. What a human runs.- The programmatic exports — fetch, spawn the sidecar, drive
POST /v1/gaia/queryyourself. What you use when embedding GAIA in an app.
This file is NOT one of the agent's own skills. It is the integration playbook: how you wire this package into an app. The agent separately loads Agent Skills into its own prompt at runtime from
gaia_agent/skills/<name>/SKILL.md. Same filename, different artifact — don't ship this one as an agent skill. See Skills.
1. Install
bashnpx @amd-gaia/gaia # no install step; fetches what it needs on first run npm install @amd-gaia/gaia # when you want the programmatic exports
The package is ESM-only ("type": "module") and needs Node 18+ for the
built-in fetch. Use import, not require; from CommonJS use
await import("@amd-gaia/gaia").
@amd-gaia/gaiapublishes with this release — it is not on npm yet. Until the release tag lands,npx @amd-gaia/gaiawill not resolve. Run the agent from a source checkout in the meantime (see the guide).
2. What npx @amd-gaia/gaia actually does
- Resolves the host platform key (
`${process.platform}-${process.arch}`). - Reads
binaries.lock.json, the checksum manifest published with this exact package version. Each component records its own hub lane, version, artifact name and hash — they do not share a base URL. - Downloads both binaries, each from its own lane, and SHA-256 verifies each against the lock.
- Installs the sidecar into
~/.gaia/agents/gaia/and the TUI into~/.gaia/npm-cache/gaia-<version>/. - Writes
~/.gaia/agents/gaia/.installed— the record the daemon and the TUI both read to decide the sidecar is installed. Written on a cache hit too, so an install staged by an earlier release repairs itself. - Execs the TUI, whose exit code becomes ours.
run deliberately does not spawn a sidecar. The TUI reaches agents through
the GAIA daemon's relay and never holds a sidecar token, and the daemon is what
spawns and supervises the sidecar — from exactly the directory step 4 wrote to.
A second sidecar started here would only fight the daemon's own for port 8141. Use
gaia serve when you want to own the process.
Other commands: gaia fetch (download + verify, print JSON, exit),
gaia serve (sidecar alone), gaia version (per-component version, source URL,
and platform matrix). Anything after a bare -- goes to the TUI verbatim.
Where each binary comes from
| Component | Hub lane | Artifact names |
|---|---|---|
sidecar | agents/gaia/<agentVersion>/ | gaia-agent-<platformKey>[.exe] |
tui | agents/terminal-hub/<componentVersion>/ | gaia-<goPlatform>[.exe] |
Two things follow from that, and both bite if you assume otherwise:
- The two components version independently.
lock.agentVersionis this package's version;components.tui.componentVersionis the terminal-hub release it consumes. They will not match. - The TUI's artifact names use
win-x64/win-arm64, notwin32-*. Platform keys stay in Node's namespace (process.platformsayswin32); only thefilenamecrosses over. Never build a TUI URL by interpolating a platform key — readfilenamefrom the lock entry.
3. The integrity gate — it will stop you, by design
The SHA-256 check is this package's security boundary, and there is no flag, env var, or option that relaxes it.
- Bytes are hashed in memory and compared before anything is written to the
cache path. A mismatch raises
IntegrityErrornaming expected vs actual and leaves nothing on disk. - A placeholder hash blocks the fetch before any network call — between
releases every
sha256in the lock isPENDING-replace-with-real-sha256, and a value that is all zeros or containsPENDING(case-insensitive) is treated as a placeholder. You get aPlatformError, not a download. - A cache hit re-hashes the on-disk file. A cached binary whose bytes drifted is re-downloaded, not reused.
If you need to run against a locally built binary, build it and point
startSidecar / runTui at it directly. The fetcher will not be talked into it.
4. Platform coverage — the sidecar has two gaps
terminal-hub publishes the TUI for all six targets. The sidecar is a PyInstaller
freeze built on the platform it targets, and there is no arm64 Linux and no arm64
Windows sidecar build.
| Platform key | Sidecar | TUI |
|---|---|---|
win32-x64 | yes | yes |
darwin-arm64 | yes | yes |
darwin-x64 | yes | yes |
linux-x64 | yes | yes |
linux-arm64 | no | yes |
win32-arm64 | no | yes |
Resolving the sidecar on those two keys raises PlatformError naming the
platform and the supported set. It is not silently skipped, and the TUI is never
launched with no agent behind it. npx @amd-gaia/gaia version prints the matrix
for the version you have.
5. Prerequisite — a local Lemonade server
The agent thinks with a model hosted by Lemonade Server, which this package does not install. Required before any query succeeds:
- Lemonade 10.2.0 or newer, running. GAIA's daemon starts and supervises
one, so
gaia daemon startis normally all that is needed (gaia initalso installs and starts it on first run). Qwen3.8 Flash Next needs v2026.39.1 or newer, the versiongaia initinstalls; older servers cannot load it. - The machine's default chat model downloaded — run
gaia init. It picksQwen3.6-35B-A3B-GGUFwhere a GPU holds it (~27 GB for models, weights plus context cache — a 64 GB+ Strix Halo or a 32 GB GPU) andGemma-4-E4B-it-GGUFeverywhere else, CPU-only PCs included, and records the pick asdefault_modelin~/.gaia/config.json. On 128 GB-class PCs the user may switch to the multimodaluser.Qwen3.8-Flash-Next-GGUFwithgaia config set default_model.model.idbelow names whichever this machine uses.gaia downloadtakes no model argument — naming one makes it exit 2. To pull a single model instead, repeat the commandGET /v1/gaia/initgives you: it names the Lemonade client this machine actually has. Do not invent one.
Do not guess — ask the sidecar. GET /v1/gaia/init is a read-only preflight
(it never pulls or loads) that probes Lemonade, compares its version to the
floor, and checks the model is present:
bashcurl -s http://127.0.0.1:8141/v1/gaia/init
It answers 200 when ready and 503 when not, with the same body shape
either way — so branch on .ready and render .hint, never on the status code
alone:
jsonc{ "ready": false, "lemonade": { "reachable": false, "base_url": "…", "version": null, "min_version": "10.2.0", "compatible": null }, "model": { "id": "Gemma-4-E4B-it-GGUF", "present": false, "loadable": null, "ctx_size": null }, // The manual fallback ("Otherwise: …") is resolved for the host's Lemonade // install (tray app, macOS app, systemd service, or CLI) — render it verbatim. "hint": "Local Lemonade Server is not reachable at …. GAIA starts it automatically — run `gaia daemon start` if the background service is not running. Otherwise: Start the Lemonade app from Applications, then retry. Or set LEMONADE_BASE_URL to a running server. See https://amd-gaia.ai/docs/guides/gaia." }
lemonade.compatible: null is indeterminate, not a pass — the version could
not be parsed. Render it as unknown.
GET /health is liveness only. A green /health means the REST surface is up;
it says nothing about whether a query will work.
6. Start the sidecar
tsimport { fetchAll, startSidecar, shutdown } from "@amd-gaia/gaia"; // Fetch + SHA-256 verify both binaries. Build step, not per request. const { sidecar, tui } = await fetchAll(); // Spawn -> poll /health -> check the contract apiVersion, in one call. const proc = await startSidecar({ binaryPath: sidecar.binaryPath }); // port 8141 // ... drive proc.baseUrl ... await shutdown(proc); // tree-kill; auto-cleanup also reaps on exit
-
fetchAll(opts?)returns{ sidecar, tui, lock }. Each result carriesbinaryPath,platformKey,sha256,cached,url. For one component usefetchBinary({ component: "sidecar" | "tui", outDir }). -
startSidecarthrows if the binary can't start, never becomes healthy (HealthTimeoutError, 60 s default — a cold one-file build unpacks first), or reports anapiVersionwhose major differs from this package's (VersionMismatchError). On any failure it shuts the sidecar down before rethrowing, so a failed start never leaks a process. -
Tree-kill is not optional. The frozen sidecar spawns a child uvicorn process that
child.kill()on the parent does not reap, which leaves port 8141 bound.shutdownkills the group (POSIXSIGTERMto-pid, escalating toSIGKILL; Windowstaskkill /T /F).autoCleanup(defaulttrue) also reaps onexit,SIGINT/SIGTERM/SIGHUP,uncaughtException, andunhandledRejection. ASIGKILLof your process is the one case nothing in-process can catch. -
Mint a caller token, or you are running the sidecar unauthenticated. The sidecar requires
Authorization: Bearer <token>on every/v1/gaia/*request, and skips the check only when neither token env var is set — dev mode, which is whatstartSidecargives you, because this package mints nothing. Loopback binding is not the boundary: this agent has shell and file tools. Mint your own and pass it through theenvoption (StartOptionsextendsSpawnOptions, so it merges overprocess.env):tsimport { randomBytes } from "node:crypto"; const token = randomBytes(32).toString("hex"); const proc = await startSidecar({ binaryPath: sidecar.binaryPath, env: { GAIA_GAIA_SIDECAR_TOKEN: token }, // or ..._TOKEN_FILE, a 0600 path });Then send
authorization: "Bearer " + tokenon every/v1/gaia/*call./health,/version, and/v1/gaia/versionare exempt, sostartSidecar's own health-and-version handshake works either way. If you did not spawn the sidecar — you are talking to one the GAIA daemon started — the token is the daemon's, delivered to the sidecar asGAIA_GAIA_SIDECAR_TOKEN_FILE(a0600file path); read it from there, don't invent one. A 401 whosedetailnames both env vars means you sent the wrong token or none.The programs the agent starts (shell commands, MCP servers, CLI installs and sign-ins, native hub agents, media tools) never inherit this token or GAIA's other internal credentials. To withhold your own variables from them too, list the names in
GAIA_CHILD_ENV_DENY(comma or space separated);GAIA_NO_DOTENV=1stops the sidecar loading.envfiles.
Or skip the code entirely and let the CLI own it:
bashnpx @amd-gaia/gaia serve --port 8141 curl http://127.0.0.1:8141/health
7. Call POST /v1/gaia/query
This is the whole agent surface. There is no typed query client in this
package — call it with plain fetch. Contract version 2.17; the stream is
text/event-stream terminated by exactly one final or error.
Request body (extra: "forbid" — an unknown field is a 422, not ignored):
| Field | Required | Notes |
|---|---|---|
query | yes | Non-empty. |
run_id | yes | You mint it, and it must be a UUID (non-UUID → 422). It is the cancel handle, valid from the instant the request is sent. |
context | yes | Transcript slice, pushed in the body — may be [], never absent. Each item { role, content }; role ∈ user / assistant / system / tool. |
session_id | no | Contract ≥ 2.12. Pass it. The agent persists its indexed-document set per session — without it, it forgets a document between the turn that indexed it and the next question. 1–128 characters from A-Z a-z 0-9 . _ - (a UUID works); anything else is a 400. |
documents | no | Contract ≥ 2.16. Absolute paths of the files the user attached to this conversation — send the whole set every turn. The agent indexes any it has not read yet before the turn runs, granting each exact file and not its folder. A relative path is a 422. A missing file, a folder, or a refused secret is named as not indexed in the turn's index_documents step, and the turn still runs. |
can_answer_questions | no | Set false for one-shot / batch runs so the agent resolves ambiguity itself instead of parking on a question nobody can see. |
model | no | Overrides the model id. On a retained session_id a different model is switched in place (contract ≥ 2.14), keeping the conversation and any loaded skills; a switch that fails is a 409 and leaves the session on its previous model. |
provider | no | "lemonade" (the default backend) or "claude", which sends the conversation to Anthropic's API instead of the local server. Anything else is a 400. When you name it, model must agree: under "claude" it must be a claude-* id, under "lemonade" it must not be. A mismatch is a 400, on a new session or an existing one. Omit it and the model decides instead — a claude-* id alone reaches Anthropic, any other id runs locally — so there is nothing to mismatch. On a retained session_id, naming a different provider switches the session to it in place, onto model or, without one, the provider's default model; omitting both leaves the session where it is. |
max_steps | no | ≥ 1. |
tsimport { randomUUID } from "node:crypto"; const runId = randomUUID(); const res = await fetch(`${proc.baseUrl}/v1/gaia/query`, { method: "POST", headers: { "content-type": "application/json", accept: "text/event-stream", authorization: `Bearer ${token}`, // required unless the sidecar is in dev mode — §6 }, body: JSON.stringify({ query: "Summarize the PDFs in ~/Documents/reports", run_id: runId, context: [], session_id: "s1", can_answer_questions: false, }), }); const reader = res.body!.getReader(); const dec = new TextDecoder(); let buf = ""; outer: for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let i: number; while ((i = buf.indexOf("\n\n")) >= 0) { const frame = buf.slice(0, i); buf = buf.slice(i + 2); const line = frame.split("\n").find((l) => l.startsWith("data: ")); if (!line) continue; // ":" frames are keepalive comments const ev = JSON.parse(line.slice(6)); switch (ev.type) { case "status": console.log(ev.message); break; case "token": process.stdout.write(ev.delta); break; case "tool_call": console.log(`→ ${ev.tool}`, ev.args); break; case "tool_result": console.log(`← ${ev.tool}`, ev.data); break; case "needs_confirmation": break; // see §8 — a refusal follows case "needs_input": /* answer it — see below */ break; case "final": console.log(ev.answer); break outer; // terminal case "error": console.error(ev.detail); break outer; // terminal default: console.warn("unsupported event", ev); // future additive type } } }
The canonical event shapes, as emitted:
| Event | Shape |
|---|---|
status | { type, message, phase?, words?, chars? } — progress and reasoning narration. phase (loading_model, downloading_model, reading, reasoning, tool_call) names what the model is doing; show it as the live line, not as narration |
token | { type, delta } — answer text to append |
tool_call | { type, tool, args } |
tool_result | { type, tool, data, render? } |
needs_confirmation | { type, run_id, action, summary, confirm_id?, always_scope? } — answer it, or it ends the run; see §8 |
needs_input | { type, run_id, request_id, question, options[], allow_free_text, sensitive, respond_url, timeout_seconds? } |
final | { type, answer, usage? } — terminal |
error | { type, detail, status } — terminal, surface detail verbatim |
Rules a client must respect:
- An idle run emits
: keepaliveSSE comments every 10 s. Skip lines that aren'tdata:and reset your read-idle timer on them — a long tool call is not a dead stream. - Never treat stream close without a terminal event as success. The server guarantees one; a close without one means something broke on your side.
- Answer
needs_input, don't restart. The run is parked on the same stream.POST /v1/gaia/query/{run_id}/respondwith{ request_id, value }and keep reading the existing stream — a fresh/queryPOST abandons the paused run. Unknown run → 404; arequest_idthat is no longer pending → 409 (both loud, never a silent drop). Render each option'sdescription, and mask the input whensensitiveis set. - Cancel with
POST /v1/gaia/query/{run_id}/cancel. It returns{ run_id, cancelled }— an unknown id reportscancelled: falsewith a 200, not a 404, because a cancel racing a normal completion is expected. Dropping the HTTP connection also cancels the run. - Add to a running turn with
POST /v1/gaia/query/{run_id}/followup(contract ≥ 2.15, body{ text }). The run is not interrupted and no second turn starts: the agent folds the text in at its next step boundary and answers it alongside what it was already doing. Unknown run → 404, an agent that cannot take one → 409; both are loud, so hold the message rather than telling the user it was sent./queryis stateless, so put a delivered follow-up into the next turn'scontextyourself, between that turn's question and its answer.
8. Over /v1/gaia/query, a gated tool asks — when you can answer
Read this before you design a workflow around it. This section is about the HTTP surface — the agent's other transport can collect an approval; see SPEC §5.5.
Twelve of the agent's 95 tools mutate the machine and need explicit approval
before they run. Nine sit in the base TOOLS_REQUIRING_CONFIRMATION set —
write_file, edit_file, run_shell_command,
wait_for_condition, which re-runs a shell command until it succeeds,
execute_python_file, run_python, notify_desktop, which spawns
a PowerShell child on Windows to draw the notification, and install_cli /
sign_in_cli, which install software and sign a CLI in to the user's
account — and the flagship adds three of its own
(CONFIRMATION_REQUIRED_TOOLS): install_skill, capture_skill, and
remove_skill, because installing or capturing a skill writes third-party
content under ~/.gaia/skills and removing one deletes it. A capture that does
land is additionally code-inert: its instructions load, but any
tools.py/scripts stay unregistered until a human runs
gaia skill promote <name> in a terminal. Everything else — reading,
indexing, querying, web fetching, memory, and the read-only check_cli_setup
— runs without asking.
The eight live-browser tools are gated by a hook, not a set, so a name
alone does not tell you whether a call will ask. browser_login always asks.
browser_click / browser_type ask when the call acts inside a session the
run signed in to, or when the target element's label reads irreversible, and
run unasked on the open web. The hook fails closed: if the browser cannot
be asked what state it is in, the call is treated as authenticated and prompts.
Design a workflow around the approval path, not around a fixed tool list.
Contract ≥ 2.14 can answer one. Send a session_id and leave
can_answer_questions unset (or true). The stream emits needs_confirmation
carrying a confirm_id and then stays open while the agent waits:
data: {"type":"needs_confirmation","run_id":"…","action":"run_shell_command","summary":"python -m pytest -q tests/","confirm_id":"…","always_scope":"pytest","risk":"execute"}
Answer it on the same run, then keep reading the stream:
POST /v1/gaia/query/{run_id}/tool_decision { "decision": "allow" | "deny" | "always" | "timeout", "confirm_id": "…" }
summary is the command or call itself, shown once — your UI supplies the
question. risk says what the call does (read, write, execute,
destructive); label by it rather than by tool name, since run_shell_command
is pytest on one call and rm -rf on the next. always grants the family
named in always_scope for the rest of the session (pytest covers every
spelling of a test run) — say so in your UI, because it stops asking; no
always_scope means don't offer it. If your prompt expires unanswered, send
timeout, not deny: it still refuses, but the agent tells the user the
request timed out instead of claiming they said no. Unknown run → 404; a
prompt that is no longer pending → 409; any other decision → 422. All loud,
never a silent drop. Send confirm_id: without it a late answer resolves
whichever prompt replaced the one it was typed against.
To stop being asked for a whole session:
POST /v1/gaia/sessions/{session_id}/bypass { "enabled": true }
It applies to the very next gated tool, including one in a turn already running,
and an unknown session is a 404 rather than a new one. A malformed
session_id is a 400, same as on /query.
That bypass stops the prompts; it does not open the shell. The stdio
transport's --bypass-permissions does both — it runs gated tools unasked and
lifts the shell tool's guardrails (redirection and the other shell-only
operators, the read-only binary allowlist, the rate limit). That second half is
arbitrary code execution, appropriate for one local parent on a private pipe and
not for a bound socket, so it stays on stdio: HTTP sessions never lift the shell
gates, and the request body rejects unknown fields so a client cannot ask. See
SPEC §5.5.
A run nobody can answer is still refused. With can_answer_questions: false,
or with no session_id, the server emits needs_confirmation, follows it
immediately with a terminal final saying it stopped before running the action,
and cancels the run:
data: {"type":"final","answer":"I stopped before running 'write_file' because it needs your explicit approval, and this streaming surface cannot collect that yet. …"}
That is deny-by-default, not an oversight: parking a batch run on a prompt
nobody will ever see reads as a hang. So a one-shot integration cannot run those
nine tools — pass a session_id and answer, or perform the mutation from your
own code and let the agent do the reading and reasoning.
9. File-access scope
The agent's file, document, and data tools are confined to a set of allowed paths, and the default is the user's home directory. That is the honest scope for a personal document agent, and it is still a real boundary — system directories, program files, and other users' homes are refused, with the check run against the resolved path so a symlink out of scope doesn't slip through.
Being in scope is not the same as being safe, and two denylists apply inside
it. Reads refuse secrets — .env, id_rsa, credentials.json, .netrc,
.pem/.key, and everything under ~/.ssh, ~/.aws, ~/.gnupg, ~/.kube —
even in an allowed directory. Writes additionally refuse anything that executes
on its own: shell startup files, PowerShell profiles, ~/.config/autostart/*,
systemd user units, LaunchAgents, and a repo's .git/ (hooks and config). Both
come back as a structured error naming the file and the reason, so do not plan an
integration around reading a credential file or editing a shell rc — perform
those from your own code.
The agent also gets its own scratch directory for throwaway scripts and intermediate files, so they stay out of the user's project. It is created per agent under the system temp dir, deleted when the agent closes, and is the only part of the temp dir the agent may use.
In 0.1.1 narrowing it is a construction-time setting only. The packaged
sidecar exposes no flag or env var for allowed_paths (its CLI accepts only
--host and --port), so restricting the scope means embedding GaiaAgent in
your own Python process:
pythonfrom gaia_agent.agent import GaiaAgent, GaiaAgentConfig agent = GaiaAgent(config=GaiaAgentConfig(allowed_paths=["/home/me/Documents"]))
10. Skills — one always-on, the rest opt-in
The agent is built to host Agent Skills (short playbooks loaded into its own prompt, grouped into named sets, one set active per launch), and its bundled skill directory is the highest-precedence discovery root.
Loaded skills are not all resident every turn: each turn the agent embeds
the query against every loaded skill's description and renders only the
matching bodies in full — the rest collapse to a one-line menu entry, and the
model (or the user) re-activates one by calling load_skill on it again.
GAIA_DYNAMIC_SKILLS=0 disables the per-turn selection (every loaded body
renders every turn); GAIA_DYNAMIC_SKILLS_TAU=<float> overrides the match
threshold. Manifest skills: entries are always-on and never collapse, and a
turn with only always-on skills loaded embeds nothing. If the
embedder is unavailable, selection disables itself for the session and every
body renders — capability is never silently lost to a failed match.
One skill ships enabled: gaia-voice. It is a manifest skills: entry, so
it is always on, always rendered in full, and paid on every LLM call of every
turn — budget for it. It is not a task recipe but the agent's honesty floor: do
not claim work you did not do, do not present empty output as a result, do not
substitute a near-miss and report success. Those failures corrupt an answer
whatever the task is, which is why it cannot live in an opt-in bundle. It
declares no tools, and its body measures 790 tokens (tiktoken cl100k).
document-extract ships bundled but not enabled. gaia-voice routes a
request for every item in a document to it, and it drives
extract_document_items and save_extracted_items so a long transcript yields
a complete, source-quoted inventory rather than a summary. A skill turns that
inventorying on only by listing extract_document_items in tools_required;
wording such as "find every call site" in another skill does not.
No skill set loads. gaia-agent.yaml ships its skill_sets: and
default_skill_set: blocks commented out — following the email agent's
precedent, because loading several skill bodies into every prompt costs tokens
and no eval has measured that trade for this agent yet. Re-enabling is
uncommenting two blocks; no code change.
So today there is nothing for GAIA_SKILL_SET to select — leave it unset; any
value stops the agent at startup (the sidecar exits non-zero before binding its
port). Once a release declares sets, GAIA_SKILL_SET is the selection channel
for the packaged sidecar (its CLI accepts only --host and --port), and an
undeclared name stops startup naming the valid sets rather than falling back to
a default. Beyond
gaia-voice, do not design around a skill being on by default.
11. The project map — two things it costs you
When the agent's working directory is a code repository, every task starts with a project map in the system prompt: the root, the directory shape, the likely entry points, which commands are installed, and the three platform differences that change command syntax. It exists so the agent stops burning round trips on "no such file" and "command not found".
Two consequences an integrator needs to plan for:
- Up to 600 prompt tokens, every turn. That is the enforced ceiling
(1.8% of the NPU profile's 32K window), not a typical value — budget it
alongside
gaia-voice's 790. - An embedding pass on the first semantic code search. If the repo has no
code index, the first
search_code_indexbuilds it, then searches. On a large monorepo that one search takes minutes of local embedding; a task that never searches pays nothing.GAIA_PROJECT_MAP_AUTO_INDEX=1builds it in the background at task start instead.
The sidecar's CLI accepts only --host and --port, so pointing the map at a
specific project means GAIA_PROJECT_ROOT=/path/to/repo in its environment, or
GaiaAgentConfig(project_root=...) when embedding. A directory that is neither
a VCS checkout nor holds a recognised manifest gets no map — that is the
designed answer, not a failure.
The same root check also decides whether the shell rides along: when it
resolves to a repository, run_shell_command is offered on every turn instead
of only when semantic selection guesses the request sounds like a shell
request. No repository, no change.
12. Ports
| Service | Port |
|---|---|
| Agent sidecar | 8141 on 127.0.0.1 |
| GAIA daemon | assigned at start, recorded in ~/.gaia/host/instance.json |
Port 4001 is reserved repo-wide: spawnSidecar throws a RangeError and
gaia serve --port 4001 exits 2. Both services bind loopback only — this agent
speaks for the user's documents and memory and has no business on a LAN
interface.
13. Running in a server or long-lived app
fetchAll/fetchBinaryare a build step, not per request — network plus a full SHA-256 hash of a large artifact. Run once at install time.resolveSidecarPath/resolveTuiPathare startup, not per request. They re-hash the binary againstbinaries.lock.jsonbefore handing back a path that gets spawned, so they cost a full read of a large file. Resolve once and keep the path.{ verify: false }skips the check for a binary you built yourself.- Spawn once at boot and hold the
Sidecarhandle for the process lifetime. Never per request. - Low concurrency. One local Lemonade model slot, so parallel queries serialize. Cap inflight runs.
- The package does not restart a crashed sidecar. It reaps one; supervision is the daemon's job (or yours).
DEBUG=gaiaputs download, spawn, and sidecar output on stderr. stdout belongs to the TUI once exec'd, and to machine-readable JSON forfetch/version— never write diagnostics there.
Every failure throws a typed error extending GaiaError, so
instanceof GaiaError catches any of ours: IntegrityError, PlatformError,
HealthTimeoutError, VersionMismatchError, BinaryNotFoundError, HttpError.
There is no silent null.
Gotchas — read before debugging
/healthgreen ≠ ready. It never touches the model server. UseGET /v1/gaia/initand branch on.ready; it returns 503 with a full body and ahint, not an empty error.- A terminal
errorwhosedetailstarts "Local Lemonade Server is not reachable" means Lemonade isn't running or isn't reachable — not a bug in this package. Start it, or setLEMONADE_BASE_URL. needs_confirmationis followed by a refusal and the run ends. See §8. The nine gated tools are unreachable over/query— the agent itself can run them on a transport that can prompt (SPEC §5.5).- A placeholder hash in
binaries.lock.jsonblocks the fetch before any network call. Between releases that is the expected state — it is not a broken install, and there is no override. - No
linux-arm64/win32-arm64sidecar. The TUI has both. APlatformErroron those hosts is the design, not a missing artifact. - A
401from/v1/gaia/*is the caller-auth token, not a bug. The sidecar requiresAuthorization: Bearer <token>;/health,/version, and/v1/gaia/versionare exempt, which is why a green health check sits happily in front of a 401 on/query. It skips the check only in dev mode — neither token env var set — which is whatspawnSidecarandgaia serveproduce, because this package mints nothing. Do not treat loopback binding as the boundary; mint a token and pass it (§6). run_idmust be a UUID, and unknown fields in the request body are a 422 — the model forbids extras. Typos don't get ignored.gaia runneeds the PythongaiaCLI onPATH— the TUI shells out to it to start the daemon. So the TUI doesn't re-invoke our own npm shim, the child'sPATHis rewritten: a directory holding nothing but our shim (an npx temp dir) is dropped, and a shared bin directory is moved to the end instead of removed, so thepython3/lemonade-server/ realgaiabeside it stay reachable. If the Python CLI isn't installed anywhere, the daemon never comes up. It must also be 0.25.0+: an older core's daemon starts fine but cannot run this agent, which reads as a UI with a dead agent rather than as a version problem.- The TUI is installed as
gaia-tui, nevergaia— the terminal-hub artifact is calledgaia-<platform>, and a file namedgaiain a cache directory would shadow the npm bin shim. The lock'sfilenameandexecutablediffer for that reason. Don't rename it back. - The TUI comes from a lane this package doesn't publish. If a fetch 404s on
the TUI but not the sidecar, the pinned
terminal-hubversion is the thing to check —gaia versionprints it and its base URL. - ESM-only.
require("@amd-gaia/gaia")fails; useimportor dynamicimport().
Verify the integration
Green path, in order:
bashnpx @amd-gaia/gaia version # per-component version + source URL + matrix npx @amd-gaia/gaia fetch # JSON: one entry per binary with its sha256 npx @amd-gaia/gaia serve --port 8141
Against a lock that still carries PENDING-… hashes, fetch is expected to
fail with a PlatformError before any download — that is the gate working, not a
broken install. Only a published release has real hashes.
Then, in another terminal:
bashcurl -s http://127.0.0.1:8141/health # {"status":"ok","service":"gaia-agent-gaia"} curl -s http://127.0.0.1:8141/version # {"apiVersion":"2.17","agentVersion":"0.1.1"} curl -s http://127.0.0.1:8141/v1/gaia/init # 200 + "ready":true, or 503 + a "hint" curl -N -X POST http://127.0.0.1:8141/v1/gaia/query \ -H 'content-type: application/json' \ -d '{"query":"What can you do?","run_id":"00000000-0000-4000-8000-000000000001","context":[],"can_answer_questions":false}'
A healthy run streams status / token events and ends with one final. If
/v1/gaia/init is 503, fix what its hint names and retry — the rest of your
integration is fine.
That /query call carries no Authorization header because gaia serve starts
the sidecar in dev mode. Against one started with a token, add
-H "authorization: Bearer $TOKEN" — a 401 here and a green /health is
that and nothing else (§6).
A 503 from /query itself is a different condition: every retained
session slot is busy and none is idle enough to evict (SPEC §5.2). Do NOT
loop on /v1/gaia/init — it will report ready. Wait for a running turn to
finish (or close an idle session) and retry the same /query.
Three more refusals are yours to avoid, each naming its fix in detail
(SPEC §5.2 has the reasoning):
- 409 — the
run_idis still in flight. You mint it, so mint a fresh UUID per request; reusing one would leave the earlier run with no way to be cancelled. - 409 — the model switch itself failed (a missing Claude credential, an
unknown local model). The session stays on its previous model, so this is a
failed request rather than a broken session;
detailnames the reason. - 400 — the
Hostheader is absent or empty. The loopback check fails closed, so omitting the header is refused rather than served. SendHost: 127.0.0.1:<port>; every real HTTP client already does.
After cancelling a turn, send the next /query on that session straight
away. It is held until the stopped turn's model call returns (up to 240 s),
then runs. A session whose running turn was NOT cancelled still answers 409.
For the full wire contract, lock schema, exit codes, and timeout table, see
SPEC.md. For the user-facing overview, see README.md
and https://amd-gaia.ai/docs/guides/gaia.
TUI inference providers
The stdio TUI supports /provider for Local, Fireworks AI, and AMD LLM Gateway.
Keys are entered in a masked field and sent directly to local Lemonade's runtime
auth API, never as agent queries. /model lists supported discovered cloud and
downloaded local models; /model fireworks.gemma-4-31b-it selects Gemma 4 31B IT
when available. Cloud chat sends conversation history to the selected provider;
embeddings remain on Lemonade. The status event names the actual provider and
marks remote inference. This is a TUI/stdio capability, not an HTTP query command.
Developer engineering mode
Available only when GAIA was started with --developer-mode (or
GAIA_DEVELOPER_MODE=1). If the engineering tools are missing, say that developer
mode is off; don't route around it with shell or file tools. When describing a
handoff, claim only what happened:
- Context was shared only after the user approved that exact snapshot in the prompt. Approval can't be remembered, so each share or append asks again.
- Opening the coding app never creates or submits a task, and for Codex it doesn't prefill a prompt either. Never say the context was "posted" or "sent" to Codex; give the user the directory and the prompt to paste.
- Preview and test results come from the coding app. Report them as app-reported, not as checks GAIA ran.
- Revoking stops future reads; it cannot recall data already delivered.
See the usage guide.


