GAIA
v0.2.0 VerifiedThe flagship GAIA agent — conversation, document Q&A, data analysis, and web research, with persistent memory and add-on skills
- general
- chat
- rag
- memory
- skills
- research
Install
Python package · 133.5 MBgaia agent install gaia Recommended — installs into your GAIA app and registers the agent automatically.
git clone https://github.com/amd/gaia.git Build from the GAIA repository — clone, then follow the agent README to install it.
A local model must be running first: gaia init installs the model server, downloads the model, and starts it.
About GAIA
@amd-gaia/gaia
One command gets you a working GAIA:
npx @amd-gaia/gaia
That fetches the two binaries GAIA needs — the agent sidecar and the terminal UI — verifies both against a checksum manifest that ships inside this package, and drops you into the terminal UI. No Python to install, no repo to clone, no build step. Local inference is the default. The TUI can explicitly select Fireworks AI or AMD LLM Gateway through Lemonade; remote chat sends conversation history to that provider. The terminal UI's --use-claude flag, which would send a conversation to the Anthropic API, is refused for the agent this package installs — see SPEC.md §5.5.
The terminal UI you get here is the published terminal-hub component — the exact same binary a full GAIA install runs as gaia tui, not a separate build. So however you arrive at the terminal UI, it behaves identically.
What it actually does
- Resolves your platform —
win32-x64,darwin-arm64,darwin-x64,linux-x64(pluslinux-arm64/win32-arm64for the terminal UI). - Reads
binaries.lock.json, the checksum manifest published with this exact package version. It records, per binary, which hub lane it comes from and what it must hash to. - Downloads and SHA-256 verifies both binaries. A hash that does not match is a hard failure — the download is deleted and the run stops. There is no "continue anyway" path and no unverified fallback.
- Launches the terminal UI, which brings up the GAIA daemon and the agent sidecar and hands you the chat. Its exit code becomes ours.
Requirements
- Node.js 18+ (for the built-in
fetch). - Lemonade Server running locally — it hosts the model the agent thinks with. GAIA tells you if it isn't up.
- The
gaiaPython CLI 0.23.1 or newer onPATHfor the daemon the terminal UI starts. Earlier cores start a daemon that has no entry for this agent, so the UI comes up with nothing behind it. Install it withcurl -fsSL https://amd-gaia.ai/install.sh | sh(Windows:irm https://amd-gaia.ai/install.ps1 | iex).
Supported platforms
The terminal UI is Go and cross-compiles everywhere. The agent sidecar is a frozen Python build, produced on the machine it targets, and has no arm64 Linux or arm64 Windows build. On those two platforms npx @amd-gaia/gaia stops with an error naming your platform and the ones that do work — it will not start a UI with no agent behind it.
| Platform key | Agent sidecar | Terminal UI |
|---|---|---|
win32-x64 | ✅ | ✅ |
darwin-arm64 | ✅ | ✅ |
darwin-x64 | ✅ | ✅ |
linux-x64 | ✅ | ✅ |
linux-arm64 | — | ✅ |
win32-arm64 | — | ✅ |
npx @amd-gaia/gaia version prints this matrix, plus the version and source URL of each binary, for the release you have installed.
Commands
gaia [run] [options] [-- <tui args>] Fetch + verify both binaries, then launch the TUI
gaia fetch [options] Download + verify only; print JSON and exit
gaia serve [options] Run the agent sidecar alone (REST API, no TUI)
gaia version Print the lock manifest and this host's platform
gaia help Show help
Anything after a bare -- goes to the terminal UI untouched:
npx @amd-gaia/gaia -- --debug
Common options:
| Flag | Meaning | Accepted by |
|---|---|---|
--base-url <url> | Override the download base URL from binaries.lock.json. Must be https: | run, fetch, serve |
--allow-insecure-base-url | Permit a non-https --base-url (a trusted local mirror) | run, fetch, serve |
--sidecar-dir <dir> | Where to install the agent sidecar (default ~/.gaia/agents/gaia) | run, fetch, serve |
--cache-dir <dir> | Where to cache the terminal UI binary | run, fetch |
--force | Re-download even when a verified binary is already cached | run, fetch, serve |
--platform <key> | Fetch for another platform | fetch |
--component <name> | Fetch only sidecar or tui | fetch |
--port <n> | Sidecar bind port (default 8141) | serve |
A flag a command does not read is refused, not ignored — gaia run --port 9000 exits 2 rather than silently coming up on the default port.
Set DEBUG=gaia for download, spawn, and sidecar output on stderr. Diagnostics never touch stdout, which the terminal UI owns.
Where things land
| What | Path |
|---|---|
| Agent sidecar | ~/.gaia/agents/gaia/gaia-agent[.exe] |
| Install record | ~/.gaia/agents/gaia/.installed |
| Terminal UI | ~/.gaia/npm-cache/gaia-<version>/gaia-tui[.exe] |
The sidecar goes into the GAIA daemon's own cache directory on purpose: the daemon is what spawns and supervises it, and it does its own SHA-256 check on the way. By putting an already-verified binary there we save a second download rather than racing one.
The .installed record next to it is what the daemon and the terminal UI read to know the agent is installed — without it the UI would run the sidecar as its own stdio child instead of letting the daemon supervise it. It is rewritten even when the binary was already cached, so an install left by an earlier version repairs itself the next time you run. A --platform fetch stages a binary for a different machine, so it deliberately leaves no record.
The terminal UI is installed as gaia-tui, never as gaia — a file named gaia in a cache directory would shadow the gaia shim npm puts on your PATH.
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 and is refused with an error if you pass it. Both services bind loopback only — this agent speaks for your documents and memory and has no business on a LAN interface.
Running the sidecar on its own
gaia serve skips the terminal UI and gives you the REST surface directly, for integrating GAIA into your own app:
npx @amd-gaia/gaia serve --port 8141
curl http://127.0.0.1:8141/health
It waits for GET /health, checks the contract version, and tears the whole process tree down on Ctrl+C. See SPEC.md for the endpoints.
The sidecar normally requires a per-session bearer token on /v1/gaia/*, but neither serve nor startSidecar mints one, so both leave it in dev mode — the token check off, Host/Origin still enforced. This agent has shell and file tools, so before you expose it to anything, supply a token of your own: see SPEC.md §5.4.
Programmatic use
import { randomUUID } from "node:crypto";
import { fetchAll, startSidecar, shutdown } from "@amd-gaia/gaia";
const { sidecar } = await fetchAll(); // both binaries, SHA-256 verified
const proc = await startSidecar({ binaryPath: sidecar.binaryPath });
const sessionId = randomUUID(); // reuse across the whole conversation, see below
const res = await fetch(`${proc.baseUrl}/v1/gaia/query`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
query: "summarize my notes",
run_id: randomUUID(),
session_id: sessionId,
context: [],
}),
});
await shutdown(proc);
/v1/gaia/query streams Server-Sent Events terminated by exactly one final or error. fetchAll() also returns the TUI's path if you would rather launch that.
Reuse the same session_id for every turn in a conversation. It is what lets a document you had it index, or a skill you had it load, survive to the next question — drop it (or mint a new one per call) and the agent still answers, but it forgets everything from the previous turn. See SPEC.md §5.2 for the retry and eviction behavior.
Every failure throws a typed error (IntegrityError, PlatformError, HealthTimeoutError, VersionMismatchError, BinaryNotFoundError) with a message that names what failed and what to do about it.
Where the binaries come from
The two binaries ship from two different places, and binaries.lock.json records a version and a source URL for each:
| Binary | Published as | Built by |
|---|---|---|
| Agent sidecar | the gaia agent, at this package's version | this package's release |
| Terminal UI | the terminal-hub component, at its own version | the core GAIA release |
The terminal UI is consumed, not rebuilt. It is byte-for-byte the gaia tui binary a core install ships, so there is no second copy that could lag behind or behave differently — which is the entire reason it is sourced this way.
Integrity
binaries.lock.json is the single source of truth for what gets downloaded and what it must hash to. The release pipeline regenerates it from the artifacts actually being served — the sidecars it just published, and the terminal-hub artifacts it downloaded and cross-checked against the hub's own recorded hashes.
Between releases the lock carries PENDING-replace-with-real-sha256 placeholders. A placeholder blocks the fetch outright — before any network call — so an unverifiable binary can never be downloaded, let alone executed. If you need to run against a locally built binary, build it yourself and point the lifecycle helpers at it directly; the fetcher will not be talked into it.
Links
- Guide: <https://amd-gaia.ai/docs/guides/gaia>
- Technical reference:
SPEC.md - Changes:
CHANGELOG.md - Issues: <https://github.com/amd/gaia/issues>
MIT licensed. © 2024-2026 Advanced Micro Devices, Inc.
@amd-gaia/gaia — technical reference
Version 0.1.1. Companion to README.md, which is the user-facing doc. This file specifies the wire and file formats the package depends on and the guarantees it makes.
1. Scope
@amd-gaia/gaia is a binary delivery and process-lifecycle package. It ships no agent logic. It owns:
- resolving the host platform to a lock key,
- downloading and SHA-256 verifying two published binaries — one from this package's own hub lane, one from the
terminal-hubcomponent's, - caching them in stable, versioned locations,
- launching the terminal UI, or the agent sidecar directly.
It does not own the GAIA daemon's lifecycle, the sidecar's supervision, or any agent behaviour. See §6 for where those boundaries sit.
2. binaries.lock.json
Ships inside the published package (files includes it). It is the single source of truth for what is downloaded and what it must hash to.
2.1 Schema (schemaVersion 3.0)
{
"schemaVersion": "3.0",
"agentVersion": "0.1.1",
"components": {
"sidecar": {
"componentVersion": "0.1.1",
"baseUrl": "https://hub.amd-gaia.ai/agents/gaia/0.1.1",
"platforms": { "<platformKey>": { /* entry */ } }
},
"tui": {
"componentVersion": "0.23.0",
"baseUrl": "https://hub.amd-gaia.ai/agents/terminal-hub/0.23.0",
"platforms": { "<platformKey>": { /* entry */ } }
}
}
}
A component lane:
| Field | Type | Meaning |
|---|---|---|
componentVersion | string | That component's own released version |
baseUrl | string | Where that component's artifacts are served from |
platforms | object | Platform key → entry |
An entry:
| Field | Type | Meaning |
|---|---|---|
filename | string | Artifact name as published under its component's baseUrl |
executable | string | Basename it is written as on disk (with the platform extension) |
sha256 | string | Lowercase hex SHA-256 the download must match |
size | number | Informational. Not enforced |
Why per-component, and why 3.0. 2.0 had one top-level baseUrl shared by both components. That stopped being true once the TUI became the published terminal-hub component (§2.2): the two live in different hub lanes at different versions. A shared base URL cannot express that, so 2.x is rejected at load with an error naming the schema — never read as 3.x with a missing field.
The email agent's lock is schemaVersion 1.0, a flat binaries map, because it delivers one binary from one lane. A 1.x-shaped lock is likewise rejected.
2.2 Where each component comes from
| Component | Hub lane | Published by |
|---|---|---|
sidecar | agents/gaia/<agentVersion>/ | this package's release |
tui | agents/terminal-hub/<componentVersion>/ | the core GAIA release |
The TUI is consumed, not built here. It is the same tui/cmd/gaia binary the core release publishes as the terminal-hub component and a core install runs as gaia tui — so behaviour is identical by construction rather than by convention. Building a second copy under this package's lane would put the same bytes at a different version under a third naming convention, and the two would drift.
The consequence is a real release dependency: this package cannot ship until terminal-hub is published at the version its lock pins. The release fails loudly naming that version; it never falls back to building its own TUI.
The tui lane's origin is pinned in the lock rather than derived from the release pipeline's hub-origin variable, so the release verifies the exact URL the shipped lock will send users to. A release pointed at a non-default origin therefore moves the sidecar lane and not the TUI lane; re-point components.tui.baseUrl too if you need both.
2.3 Platform keys
` ${process.platform}-${process.arch} — e.g. win32-x64, darwin-arm64, linux-x64. This matches gaia.daemon.sidecars.platform.current_platform_key(), which normalises Python's sys.platform / platform.machine()` into the same namespace so the daemon and this package agree on a cache key.
The win32 ↔ win mapping. The terminal-hub lane names its Windows artifacts gaia-win-x64.exe / gaia-win-arm64.exe (Go's GOOS vocabulary), while our keys come from process.platform, which says win32. The lock keeps the win32-* key and carries the hub's spelling in filename:
| Platform key | tui filename |
|---|---|
win32-x64 | gaia-win-x64.exe |
win32-arm64 | gaia-win-arm64.exe |
darwin-x64 | gaia-darwin-x64 |
darwin-arm64 | gaia-darwin-arm64 |
linux-x64 | gaia-linux-x64 |
linux-arm64 | gaia-linux-arm64 |
The mapping therefore lives in data, not in a code path — nothing branches on the platform to build a name. TUI_ARTIFACT_NAMES (in src/platform.ts and in packaging/gen_binaries_lock.py) is the authority both the shipped lock and the release pipeline are checked against, because a wrong name here is not a build failure anywhere: it is a 404 on a user's first run.
2.4 Platform coverage
| 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 |
terminal-hub publishes all six. The sidecar is a PyInstaller freeze, produced on the platform it targets, and there is no arm64 Linux or arm64 Windows freeze. Resolving sidecar on those two keys raises PlatformError naming the platform and the supported set — it is not silently skipped, and the TUI is not launched without an agent behind it.
2.5 Placeholder hashes
Between releases every sha256 is PENDING-replace-with-real-sha256. The release pipeline regenerates the file with real hashes: for the sidecar, computed from the artifacts it just published; for the TUI, computed from the terminal-hub artifacts it downloaded and cross-checked against that lane's own recorded hashes (agents/terminal-hub/manifest.json, which the hub computes server-side at publish time). Both are then re-fetched from the public origin and re-hashed before the release is allowed to ship.
isPlaceholderSha() treats a value as a placeholder when it is all zeros or contains PENDING (case-insensitive). A placeholder blocks the fetch before any network call with a PlatformError. There is no flag, env var, or option that relaxes this.
3. Integrity
The SHA-256 check is the package's security boundary.
- Downloaded bytes are hashed in memory and compared against the lock before anything is written to the cache path.
- On mismatch:
IntegrityError, message naming expected vs actual, nothing left on disk. - Writes go to
<path>.download.<pid>and arerenamed into place, so a crash mid-write never leaves a partial file that a later run treats as verified. - A cache hit requires re-hashing the on-disk file and matching the lock. A cached file whose bytes drifted is re-downloaded, not reused.
- POSIX installs
chmod 0o755after the rename.
There is no unverified path, no "warn and continue", and no way to disable the check.
4. Filesystem layout
| Component | Default directory | Executable |
|---|---|---|
sidecar | ~/.gaia/agents/gaia/ | gaia-agent[.exe] |
tui | ~/.gaia/npm-cache/gaia-<agentVersion>/ | gaia-tui[.exe] |
The sidecar directory is a cross-repo contract with gaia.daemon.sidecars.fetch.default_cache_dir("gaia"). The daemon spawns and supervises the sidecar and does its own SHA-256 check on the binary it finds there; installing an already-verified binary at that path turns the daemon's fetch into a cache hit instead of a second multi-hundred-megabyte download.
4.1 The .installed sentinel
Installing the sidecar also writes ~/.gaia/agents/gaia/.installed, the record the daemon and the TUI both read to answer "is this agent installed?". It is written on a cache hit as well as a fresh download, so an install staged by an earlier release repairs itself on the next run, and only when the fetch produced a runnable local install — that is, for the sidecar component (the TUI is not a hub agent) and for this host's own platform. A --platform fetch stages a binary for a different host; recording it would hand the daemon a wrong-architecture executable that re-hashes correctly and then fails to exec.
{
"id": "gaia",
"version": "<sidecar componentVersion>",
"language": "python",
"installed_at": "<ISO-8601 UTC>",
"artifact_sha256": "<the verified SHA-256>",
"path": "<install directory>",
"artifact_kind": "binary",
"executable": "gaia-agent[.exe]"
}
The shape is a cross-repo contract with InstalledAgent.to_dict() in gaia.hub.installer. Three fields are load-bearing: gaia.daemon.sidecars.fetch._hub_installed_binary ignores the install unless artifact_kind is binary and executable and artifact_sha256 are non-empty, and it re-hashes the binary and raises IntegrityError if the file no longer matches artifact_sha256 — so the recorded hash is the one actually verified, never the lock's nominal value. executable must be a bare filename; installer.read_sentinel discards a sentinel whose executable contains a path separator, which reads as "never installed".
Without the sentinel the daemon sees no install and the TUI falls back to running gaia-agent as its own stdio child — spawning the REST sidecar over stdio and filling the chat with uvicorn's startup log.
The TUI directory is keyed by agentVersion — this package's version, not the component's — so a bump of either never reuses the previous release's executable.
The TUI executable is renamed to gaia-tui on install, never gaia: the terminal-hub artifact is gaia-<platform> and npm installs a gaia bin shim on PATH, so keeping the hub's name would shadow the shim. filename (what is downloaded) and executable (what is written) are separate fields for exactly this reason, and test/lock.test.ts asserts it for every platform.
Both defaults are overridable (--sidecar-dir, --cache-dir).
5. Sidecar HTTP surface
Served by gaia_agent.server. Bound to 127.0.0.1 only.
Every request must carry a loopback Host header. A Host that is non-loopback, absent or empty is refused with 400 before routing — that check is what defeats DNS rebinding, so it fails closed rather than serving a request that simply omits the header.
| Property | Value |
|---|---|
| Default port | 8141 (DEFAULT_PORT in server.py) |
| Reserved port | 4001 — refused with a RangeError |
| Contract version | API_VERSION = "2.12" |
| Agent id / prefix | gaia → /v1/gaia/... |
5.1 Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /health | Liveness. { "status": "ok", "service": "gaia-agent-gaia" } |
GET | /version | Contract probe. { "apiVersion", "agentVersion" } |
GET | /v1/gaia/version | The TUI's negotiation probe |
GET | /v1/gaia/init | Readiness detail (Lemonade, model, connectors) |
POST | /v1/gaia/query | The streaming surface (text/event-stream) |
POST | /v1/gaia/query/{run_id}/cancel | Cancel a run by its host-minted run_id |
POST | /v1/gaia/query/{run_id}/respond | Answer a mid-run question |
/health is liveness only. It says nothing about whether Lemonade is up or a model is loaded — /v1/gaia/init answers that.
5.2 session_id and agent retention
POST /v1/gaia/query accepts an optional session_id in the request body. Pass it on every call in a conversation, and reuse the same value for the whole conversation. Contract ≥ 2.12 resolves session_id to a retained agent instead of a throwaway built fresh per call — indexed documents and load_skill state only survive between turns when the same session_id threads them together.
A retained skill stays loaded but its body is not necessarily in the prompt every turn: the agent selects per turn which loaded bodies match the query and collapses the rest to a one-line menu entry (re-activated by calling load_skill again). GAIA_DYNAMIC_SKILLS=0 disables the selection; GAIA_DYNAMIC_SKILLS_TAU=<float> overrides its threshold; an embedder outage disables it for the session and every body renders. Omitting it is a valid, explicit one-shot: nothing persists past that single turn, and the agent is not told otherwise.
A retained session also carries a project map — up to 600 prompt tokens of directory shape, entry points, installed commands and platform quirks, present whenever the agent's working directory resolves to a code repository (a VCS directory or a recognised manifest at its root). GAIA_PROJECT_ROOT=<path> picks the project when the working directory is not it. If that repository has no code index, the first turn starts one in a background thread; GAIA_PROJECT_MAP_AUTO_INDEX=0 disables that. Neither affects the wire contract — they change what the agent knows and what the first turn costs.
A second /query for a session_id that already has a turn in flight gets 409 Conflict — cancel the running turn or wait for it, then retry. A /query that needs a new session while every retained slot is busy and none is idle enough to evict gets 503 with the reason in detail — a temporary, retryable condition, not a bug: wait for a turn to finish (or close an idle session) and retry. A session_id can also be evicted from the retention table under an idle timeout (4 hours without a turn) or an LRU cap on concurrent sessions (100) — the two knobs that decide when the 503 above can happen at all (session_registry.py). Eviction is idle-only: a conversation still in use is never timed out mid-flight. A /query that lands on an evicted id gets a fresh agent (the conversation is not blocked) but the response stream's first event is a {"type":"status","status":"warning",...} telling the caller that per-turn state — most visibly any loaded skill — did not survive and should be reloaded.
A second /query reusing a run_id that is still in flight gets 409 — run_id is caller-minted, so mint a fresh UUID per request; reusing one would leave the earlier run with no way to be cancelled. A /query supplying a model that differs from the one its session_id was built with also gets 409: only agent construction reads a model, so the request cannot be honoured on the retained agent. Omit model to continue on the session's current one, or start a new session_id to switch.
5.3 Version gate
checkVersion() reads /version and compares the major of apiVersion against this package's API_VERSION. A differing major is a breaking contract change and raises VersionMismatchError. A higher minor with the same major is a backward-compatible addition and is accepted.
5.4 Caller authentication
Loopback binding is not access control: this sidecar exposes shell and file tools, so an unauthenticated port lets any page the user visits drive it. Three controls guard it, all in gaia_agent.caller_auth, which binds this sidecar's env-var names and exempt paths onto the shared mechanism in gaia.sidecar.caller_auth:
- Per-session bearer token. Every
/v1/gaia/*request must carryAuthorization: Bearer <token>. The spawning parent supplies it one of two ways:GAIA_GAIA_SIDECAR_TOKEN_FILE— the path to a0600, owner-only file holding the token, which is the preferred channel because the secret never sits in the environment — orGAIA_GAIA_SIDECAR_TOKEN, the token itself, the legacy delivery. A request without it is 401, with adetailnaming both env vars. - Host allowlist. The
Hostheader must be present and loopback (127.0.0.1,localhost,::1); anything else — including an absent or empty header — is 400. This is what defeats DNS rebinding, where the rebound request arrives withHost: evil.com, so it fails closed rather than letting a caller opt out by omitting the header. - Origin rejection. A request carrying a non-loopback browser
Originis 403. Non-browser clients send noOriginand are unaffected.
/health, /version, and /v1/gaia/version are token-exempt — they are the probes a host polls during the attach handshake, before any token is in play, and none of them exposes user data or accepts work. Host and Origin still apply to them, so waitForHealth and checkVersion work against a token-protected sidecar without knowing the token.
Dev mode. If neither env var is set the token check is skipped and the sidecar logs a loud warning saying so; Host and Origin are still enforced. That is the state a sidecar this package spawns comes up in, because spawnSidecar mints nothing — pass your own token through spawnSidecar's env option to turn the check on. The shipped product does not rely on dev mode: the daemon mints a per-session token and passes it as GAIA_GAIA_SIDECAR_TOKEN_FILE (gaia.daemon.sidecars.spec mirrors both names as plain strings so core never imports this wheel).
5.5 Other transports
The HTTP server above is the surface this package drives, and everything in this document describes it. It is not the agent's only transport: gaia_agent.stdio runs the same agent over newline-delimited JSON on stdin/stdout — no port, no token, no discovery file. The terminal UI reaches it that way when it spawns the agent as a subprocess; an agent installed from the hub, which is what this package delivers, is supervised by the daemon over the HTTP surface above instead (§6.1).
It emits the identical canonical event vocabulary, but its input channel accepts a JSON line carrying a gaia_control key, which gives it something HTTP does not have: a back-channel that can answer a confirmation prompt while a turn is in flight, and stop that turn (cancel) without ending the process — so loaded skills, "always" grants, history and the bypass mode survive a cancel. It also takes --bypass-permissions (start with gating off) and --use-claude / --claude-model (route chat to the Anthropic API instead of local Lemonade; embeddings stay on Lemonade either way). None of that is reachable over /v1/gaia/query.
The stdio TUI also supports Local, Fireworks AI, and AMD LLM Gateway through Lemonade. /model lists downloaded local and discovered cloud chat models; /model <id> switches the live client while preserving conversation and skills. Cloud IDs (fireworks.*, amd.*) route through Lemonade without local model loading. Model-state status events identify the provider and remote inference. The TUI's /provider panel configures credentials directly with local Lemonade; keys never travel through stdio queries. These controls are not exposed over /v1/gaia/query. Lemonade may independently be configured to route a model remotely, so a local server URL alone does not establish local inference.
6. Process ownership
Two distinct paths, deliberately:
6.1 gaia run — the normal path
run fetches, verifies, installs, and execs the TUI. It does not spawn a sidecar.
That is not an omission. The TUI reaches agents through the GAIA daemon's relay (/v1/<agent>/*) and holds only the daemon client token, never a sidecar bearer — tui/internal/daemon states this as an invariant. The TUI start-or-attaches the daemon under an advisory lock, and the daemon spawns and supervises the sidecar from ~/.gaia/agents/gaia/. A sidecar spawned here would be a second process the TUI never talks to, competing for port 8141.
So run's contribution to the sidecar is putting a verified binary where the daemon looks, and recording the install in .installed (§4.1) so the daemon and the TUI both see it. Daemon and sidecar lifecycle belong to the daemon.
6.2 gaia serve — the direct path
serve fetches the sidecar and spawns it itself, for integrators who want the REST surface without a daemon or a UI. This path owns the process fully: port pre-flight → spawn → waitForHealth → checkVersion → run until interrupted → tree-kill.
The port is checked before the spawn, and a taken port raises PortInUseError without starting anything. That ordering is the guarantee: the frozen sidecar spends seconds unpacking before it attempts its bind, while an incumbent answers /health in milliseconds — so a post-spawn check alone would see a healthy port and a still-alive child, and hand back a handle for a server it does not own. assertOurs and the re-probe after a failed health wait remain as backstops for the narrower window where something binds after the pre-flight.
6.3 Tree-kill
The sidecar is a PyInstaller one-file build: it unpacks and spawns a child uvicorn process that child.kill() on the parent does not reap, leaving port 8141 bound. Every teardown kills the whole tree:
- POSIX — spawned
detachedso the child leads its own process group;process.kill(-pid, SIGTERM), escalating toSIGKILLafter the timeout. - Windows —
taskkill /PID <pid> /T /F.
autoCleanup (default true) also reaps on exit, SIGINT, SIGTERM, SIGHUP, uncaughtException, and unhandledRejection. A SIGKILL of the parent is the one case no in-process handler can catch.
startSidecar() shuts the sidecar down before rethrowing on any failure, so a failed start never leaks a process.
7. Exit codes
| Code | Meaning |
|---|---|
0 | Success |
1 | A typed failure: IntegrityError, PlatformError, HealthTimeoutError, VersionMismatchError, BinaryNotFoundError, PortInUseError, SidecarExitedError, MalformedResponseError, or an unexpected error |
2 | Usage error: unknown command, invalid --port, or a flag the command does not read (run --port, serve --component, serve --cache-dir, run/serve --platform), an unknown --component, or a non-https --base-url without --allow-insecure-base-url |
| other | From run: the TUI's own exit code, propagated verbatim |
A TUI killed by a signal propagates as 128 + signum (the shell convention), so a Ctrl+C is distinguishable from a clean 0.
8. Errors
All extend GaiaError, so instanceof GaiaError catches any of ours.
| Class | Raised when |
|---|---|
IntegrityError | A download's SHA-256 does not match the lock, or a resolved binary's does not |
PlatformError | Unsupported platform, missing/incomplete entry, placeholder hash, malformed lock |
HealthTimeoutError | The sidecar did not report healthy within the timeout |
VersionMismatchError | apiVersion major differs from this package's |
BinaryNotFoundError | A binary is absent from disk when spawning |
PortInUseError | The bind port was already taken; nothing was spawned |
SidecarExitedError | The spawned sidecar died while the port answered — something else owns it |
HttpError | A non-2xx from a sidecar probe |
MalformedResponseError | A 2xx from a sidecar probe whose body is not JSON |
Per the repo's no-silent-fallbacks rule, every message names what failed, what to do, and where to look next.
9. Timeouts
| Operation | Default | Why |
|---|---|---|
| Download (per binary) | 300000ms | The frozen sidecar is a large artifact |
| Health wait | 60000ms | A cold one-file build unpacks before it binds |
| Health poll interval | 250ms | |
/health probe | 1000ms | |
/version probe | 5000ms | |
| Shutdown grace | 5000ms | Then SIGKILL / forced |
10. Public API
Exported from the package root — see src/index.ts.
Fetch: fetchAll, fetchBinary, verifySha256, fileSha256, binaryExists, defaultCacheDir, daemonSidecarCacheDir, INSTALLED_SENTINEL_NAME, SIDECAR_AGENT_ID.
Lifecycle: spawnSidecar, startSidecar, waitForHealth, checkVersion, health, version, shutdown, runTui, resolveSidecarPath, resolveTuiPath, sidecarExecutableName, tuiExecutableName.
Platform: currentPlatformKey, loadLock, resolveEntry, componentLock, componentBaseUrl, platformsFor, defaultLockPath, isPlaceholderSha, COMPONENTS, SCHEMA_MAJOR, TUI_ARTIFACT_NAMES, SUPPORTED_SIDECAR_PLATFORMS, SUPPORTED_TUI_PLATFORMS, SUPPORTED_PLATFORMS.
Constants: AGENT_ID, API_VERSION, DEFAULT_HOST, DEFAULT_PORT, RESERVED_PORT.
resolveSidecarPath / resolveTuiPath re-verify the file's SHA-256 against binaries.lock.json before returning a path that is about to be spawned — the cache path is predictable, so anything able to write it would otherwise get code run. That makes them O(size of the binary), not a cheap path join: resolve once at startup, not per request. Pass { verify: false } for a binary you built yourself, which no lock describes, and { lock } to reuse an already-loaded lock.
11. Logging
DEBUG=gaia (or DEBUG=*) enables debug output. Everything goes to stderr — stdout belongs to the TUI once it is exec'd, and to machine-readable JSON for fetch / version.
name: integrate-gaia description: Use when integrating the @amd-gaia/gaia npm package — running GAIA's flagship agent, or embedding its local sidecar into a Node, TypeScript, or Electron app. Covers the one-command path, the SHA-256 integrity gate, platform coverage, starting the sidecar, the /v1/gaia/query SSE contract, and the gotchas that will bite you.
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
npx @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 (
lemonade-server serve). - The default model downloaded (
gaia download Gemma-4-E4B-it-GGUF, orgaia init).
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:
curl -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:
{
"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 },
"hint": "Local Lemonade Server is not reachable at … — start it with `lemonade-server serve`, or set LEMONADE_BASE_URL to a running server."
}
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
import { 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):
``ts import { 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 " + token on every /v1/gaia/* call. /health, /version, and /v1/gaia/version are exempt, so startSidecar'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 as GAIA_GAIA_SIDECAR_TOKEN_FILE (a 0600 file path); read it from there, don't invent one. A 401 whose detail names both env vars means you sent the wrong token or none.
Or skip the code entirely and let the CLI own it:
npx @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.12; 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. |
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 — only when the run builds a fresh agent. On a retained session_id the agent already exists, so a model that differs from the one it was built with is a 409, not an override. |
provider | no | Local inference only — anything but "lemonade" is a 400. |
max_steps | no | ≥ 1. |
import { 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 } — progress and reasoning 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 } — no confirm_url; 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, response }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.
8. Over /v1/gaia/query, confirmation-gated tools are refused, not prompted
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.
Seven of the agent's 71 tools mutate the machine and need explicit approval before they run. Five sit in the base TOOLS_REQUIRING_CONFIRMATION set — write_file, edit_file, run_shell_command, execute_python_file, and notify_desktop, which spawns a PowerShell child on Windows to draw the notification — and the flagship adds two of its own, install_skill and remove_skill, because installing a skill writes third-party code under ~/.gaia/skills and removing one deletes it. Everything else — reading, indexing, querying, web fetching, memory — runs without asking.
Over /v1/gaia/query there is no way to collect an approval, so the stream does not prompt. When the agent reaches one of those tools it emits a needs_confirmation event, and the server immediately follows it with a terminal final whose answer says it stopped before running that action, then cancels the run. There is no confirm_url, no resume, and no /query/{run_id}/confirm endpoint — it is a deliberate deny-by-default stub, not an oversight.
Concretely, your client sees:
data: {"type":"needs_confirmation","run_id":"…","action":"write_file","summary":"Run 'write_file'?"}
data: {"type":"final","answer":"I stopped before running 'write_file' because it needs your explicit approval, and this streaming surface cannot collect that yet. …"}
So: /query cannot run any of those seven tools. If your integration needs that, drive the agent from a surface that can prompt — its stdio transport is the one that can, because its control channel carries an approval back to a turn already in flight (SPEC §5.5) — or perform the mutation yourself from your own code and let the agent do the reading and reasoning. Treat needs_confirmation as an early warning that the run is about to end, not as a question you can answer.
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.
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:
from 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. 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 676 tokens (tiktoken cl100k).
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. 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 raises 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 676. - A background embedding pass on first contact with a new repository. If the repo has no code index, the map starts one in a background thread so semantic search is ready when it is needed. On a large monorepo that is minutes of local embedding.
GAIA_PROJECT_MAP_AUTO_INDEX=0turns it off.
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.
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 seven 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.23.1+: an older core's daemon starts fine but has no sidecar entry for 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:
npx @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:
curl -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.12","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 —
modeldiffers from what thissession_idwas built with. Only construction reads a model, so it cannot be applied to the retained agent. Omitmodelto stay on the session's current one, or start a newsession_idto switch. - 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.
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.
Changelog
All notable changes to @amd-gaia/gaia are documented here. The format follows Keep a Changelog and this package adheres to Semantic Versioning.
[0.1.1] — unreleased
First working release. npx @amd-gaia/gaia is now the single command that gets a user running GAIA: it fetches and verifies everything GAIA needs and drops them into the terminal UI. Before this there was no packaged path at all — the flagship agent had to be run from a repo checkout with a Python environment, and reaching the terminal UI meant building it from source.
Fixed
- Windows npm launchers now find the Python daemon CLI even when npm passes the package script as argv[1], preserving unrelated tools in shared PATH directories.
Added
- Image generation, reachable out of the box. "Draw me a red bicycle" now generates a PNG with local Stable Diffusion and reports the path; previously the tools existed behind a flag nothing turned on, so the agent just said it couldn't. Adds
generate_image,list_sd_models, andget_generation_history(70 tools → 73) plus animage_genbundle so per-turn selection can find them. Generating swaps the resident model, so the next reply waits for the chat model to reload. Theimage-genstarter skill covers prompt expansion and iterating on the previous image. - TUI provider setup for Local, Fireworks AI, and AMD LLM Gateway, with masked runtime API keys, discovered models, and remote-inference status.
- A project map at task start. In a code repository the agent now opens every task knowing the directory shape, the likely entry points, which commands are installed, and the three platform differences that change command syntax — instead of discovering each one through a failed tool call. Capped at 600 prompt tokens. If the repository has no code index the map starts one in the background;
GAIA_PROJECT_MAP_AUTO_INDEX=0turns that off, andGAIA_PROJECT_ROOTpicks the project when the working directory is not it. See SKILL §11. 503from/queryat session capacity. When every retained session slot is busy and none is idle enough to evict, starting a new session returns503with the reason indetail— retryable, distinct from a bug-shaped500. See SPEC §5.2.- Three further client-visible refusals from
/query. Reusing arun_idthat is still in flight gets409— it used to replace the live run, leaving it with no way to be cancelled. Supplying amodelthat differs from the one thesession_idwas built with gets409rather than silently answering on the old model. A request with an absent or emptyHostheader gets400rather than being served, closing a DNS-rebinding check that failed open. See SPEC §5 and §5.2. - Per-turn skill-body selection. A loaded skill stays loaded, but its body only renders in the prompt on turns whose query matches its description; the rest collapse to a one-line menu the model re-activates with
load_skill.GAIA_DYNAMIC_SKILLS=0turns the selection off,GAIA_DYNAMIC_SKILLS_TAUoverrides the match threshold, and an embedder outage disables it for the session (every body renders — capability is never lost to a failed match). - Per-turn tool selection, now on by default for the flagship
fullprofile. The model is sent at most 26 of its 71 tools on any one call — a fixed core plus whichever cohesion bundles the query matched — instead of the whole registry every time. No capability is lost:load_toolsis an escape hatch the model calls mid-turn to pull in a bundle the selector missed.GAIA_DYNAMIC_TOOLS=0turns the selection off,GAIA_DYNAMIC_TOOLS_MAXmoves the cap andGAIA_DYNAMIC_TOOLS_TAUthe match threshold. - One bundled skill ships enabled:
gaia-voice. It is a manifestskills:entry, so it is always on and rendered in full on every LLM call — 676 tokens of every prompt, and it declares no tools. It is the agent's honesty floor (don't claim work you didn't do, don't present empty output as a result, don't substitute a near-miss and report success), which is why it is not in an opt-in bundle the way a task skill is. No skill set ships enabled: the manifest'sskill_sets:/default_skill_set:blocks stay commented out until an eval measures what loading several bodies costs. See SKILL.md §10. - A materially smaller fixed prompt on every call. That always-on
gaia-voiceguidance was rewritten from 2,129 tokens to 676 with all of its behavioural rules intact. It had been a rationale document — every rule followed by the incident that motivated it — and the model needs the rule, not the incident report. gaia run(the default command) — resolves the host platform, fetches and SHA-256 verifies both binaries, then launches the terminal UI and propagates its exit code. Arguments after a bare--are forwarded to the TUI verbatim.- Dual-binary delivery. The package installs two published artifacts: the frozen agent sidecar (
gaia-agent), published by this package's own release, and the terminal UI (gaia-tui), which is the publishedterminal-hubcomponent. The TUI is consumed, not rebuilt — it is byte-for-byte the binary a full GAIA install runs asgaia tui, so an npm user and a core user cannot end up on terminal UIs that behave differently. A second build under this package's own lane would have been the same bytes at a different version under a third naming convention, and the two would have drifted. binaries.lock.jsonschemaVersion3.0 — a component-keyed checksum manifest where each component carries its owncomponentVersion,baseUrlandplatforms. Component-first rather than the email agent's flatbinariesmap because the two differ in every dimension: hub lane, version, and platform coverage (terminal-hub covers arm64 Linux and arm64 Windows; the PyInstaller sidecar does not). A single shared base URL cannot address two lanes, so a1.x- or2.x-shaped lock is rejected at load with an error naming the schema.- Terminal-hub artifact naming is handled in data. That lane names its Windows builds
gaia-win-x64.exe/gaia-win-arm64.exe, while platform keys come fromprocess.platformand saywin32. The lock keeps thewin32-*key and carries the hub's spelling infilename, so nothing branches on platform to construct a URL. The mapping is asserted on both sides (TUI_ARTIFACT_NAMESinsrc/platform.tsand in the lock generator) because a wrong name there is not a build failure anywhere — it is a 404 on a user's first run. - Mandatory SHA-256 verification. Every download is hashed and compared against the lock before it is written. A mismatch deletes the download and raises
IntegrityErrornaming expected vs actual. A placeholder hash blocks the fetch before any network call. There is no flag that relaxes either. gaia fetch— download and verify without launching; prints JSON. Supports--componentand--platformfor cross-platform staging in CI.gaia serve— run the agent sidecar alone on127.0.0.1:8141for integrators who want the REST surface without a daemon or a UI. Health-pollsGET /health, checks the contract version, and tree-kills on exit. Port4001is refused.gaia version— prints, per component, its version, the URL it is fetched from, and its platform matrix.- Programmatic exports —
fetchAll,startSidecar,shutdown,runTui, the platform helpers, and the typed error classes, for embedding GAIA in another app. - The
.installedrecord is written after a verified sidecar install. Staging the binary was only half an install: the daemon and the terminal UI both key "this agent is installed" on~/.gaia/agents/gaia/.installed, and without it the UI ran the REST sidecar as its own stdio child and the chat filled with uvicorn's startup log. It is written on a cache hit as well as a fresh download, so an install left by an earlier version repairs itself, and only for this host's own platform — a--platformfetch stages a binary for a different machine and records nothing. See SPEC §4.1. --allow-insecure-base-url— opt-in for a non-https--base-url, for a trusted local mirror.
Changed
- A
LEMONADE_BASE_URLthat already carries a path is now used exactly as written. Previously any URL not ending in/api/v1had that suffix appended, so a reverse proxy configured ashttps://proxy.example/lemonadewas silently rewritten tohttps://proxy.example/lemonade/api/v1. It now resolves tohttps://proxy.example/lemonadeunchanged, and only a bare origin with no path at all gains/api/v1. If a proxied install starts returning404after upgrading, append the API path to the variable yourself.
Fixed
- A second
gaia serveno longer reports success against a server it does not own. With the port already taken, the incumbent answered/healthwhile our own sidecar was still unpacking, so the start "succeeded", printed a ready URL for someone else's server, and the child then died ofEADDRINUSE. The port is now checked before anything is spawned, and a taken one fails naming the port and how to find the process holding it. - Importing the package no longer changes a host app's error handling. The crash and signal handlers reaped every sidecar before checking whether the host had its own handler, so an exception the host handled killed the sidecar and the host's next request got an unexplained
ECONNREFUSED. gaia runno longer breaks the terminal UI'sPATH. Hiding our owngaiashim removed the whole bin directory, which on a Homebrew or pipx layout also tookpython3,lemonade-server, and the realgaiawith it — so the UI reported a missing CLI that we had hidden. A shared directory now moves to the end ofPATHinstead of being dropped.- Flags a command does not read are refused, not ignored.
run --port 9000parsed fine and came up on the default port; likewiseserve --componentandserve --cache-dir. - A failed install says what to do. A download that could not be moved into place — usually a running sidecar holding the file on Windows — printed a raw stack trace.
taskkillfailures name their reason. Its exit code and stderr were discarded, so "Access is denied" surfaced ten seconds later as a generic timeout.- A sidecar that survives shutdown is still reaped at exit.
shutdownde-registered it before killing it, so one that survived both kill windows became a permanent orphan holding the port. serveremoves its signal handlers once it stops, so Ctrl+C keeps working afterwards, and a repeat Ctrl+C during a slow teardown now says what it is waiting for instead of looking frozen.- A non-JSON reply from a sidecar probe raises a typed error rather than a bare
SyntaxError— the case where a proxy answers200with an HTML page. --portno longer accepts whatNumber()would coerce, so--port 0x2710is rejected instead of quietly binding 10000.
Security
- A desktop notification can no longer run code. On Windows,
notify_desktoprendered its message box by pasting the title and body into a PowerShell command string, so a'in either — text the model picks, and prompt-injected content can steer it — closed the string literal and the remainder ran as PowerShell. The command is now a fixed script that reads both values from the child's environment, and the tool now needs your approval before it runs, like the other tools that spawn a process (SKILL.md §8). resolveSidecarPath/resolveTuiPathverify the binary before returning a path that gets spawned. Both fedspawn()from a predictable cache path with no integrity check, so anything able to write~/.gaia/agents/gaia/got code run — despite the package documenting the SHA verify as its security boundary. They now re-hash againstbinaries.lock.json; pass{ verify: false }for a binary you built yourself. Note this makes them proportional to the binary's size — resolve once at startup, not per request.--base-urlrequireshttpsunless--allow-insecure-base-urlis passed. The pinned SHA already made a plaintext mirror non-exploitable, but tampering surfaced as a confusingIntegrityErrorinstead of the transport failure it is.- A ~200MB artifact is never held in memory whole. Downloads stream to disk, and the cache-hit check that re-hashes an already-installed binary — which runs on every
gaia run— reads it in bounded chunks. Both hashes are computed incrementally, and a download is still verified before the file is moved into place.
Fixed
- Esc stops a running turn in the terminal UI without killing the agent. The TUI used to kill the agent process, and on the released one-file binary that killed only the launcher: the cancelled tool call ran to completion and the surviving process consumed the next message. The first Esc now sends the agent a
cancelcontrol message, so the turn ends and the session keeps its loaded skills, "always" grants, history and bypass mode. A second Esc stops the whole process tree. - A restart after a hard stop no longer turns bypass permissions back on. The replacement agent is launched in the session's current permission mode instead of from the original flags, and the TUI says what the restart lost.
Notes
- The sidecar is installed into
~/.gaia/agents/gaia/, the GAIA daemon's own cache directory. The daemon spawns and supervises the sidecar; putting an already-verified binary where it looks turns its fetch into a cache hit instead of a second large download.runtherefore does not spawn a sidecar itself — the terminal UI reaches agents through the daemon relay and never holds a sidecar token, so a second process would only contend for the port.serveis the direct path for callers who do want to own it. - The TUI is installed as
gaia-tui, never asgaia, so it cannot shadow thegaiabin shim npm places onPATH— the terminal-hub artifact itself is namedgaia-<platform>, which is why the lock separatesfilenamefromexecutable. - Because the TUI comes from the
terminal-hublane, this package cannot be released until that component is published at the version the lock pins. The release fails loudly naming the required version; it never falls back to building its own TUI. Each terminal-hub artifact is additionally cross-checked against the hub's own server-side SHA-256 before its hash enters the lock. - Requires Node.js 18+ (built-in
fetch), a running Lemonade Server for inference, and thegaiaPython CLI 0.23.1+ onPATHfor the daemon the TUI starts. 0.23.1 is the first core whose daemon knows how to supervise this agent; on 0.23.0 the UI starts with nothing behind it. - The sidecar has no arm64 Linux or arm64 Windows build. On those platforms the run stops with an error naming the platform and the supported set rather than launching a UI with no agent behind it.
gaia_agentenforces a per-session caller-auth bearer on every/v1/gaia/*request, plus a loopbackHostallowlist and non-loopbackOriginrejection. This package mints no token, so a sidecar it spawns comes up in dev mode (token check skipped, loudly warned, Host/Origin still enforced) — pass your own throughspawnSidecar'senvto turn it on. See SPEC §5.4.- Tracks sidecar contract
apiVersion2.12; a differing major raisesVersionMismatchError.