@agent-ops/gemini-nats-channel
v0.8.0
Published
NATS channel bridge for gemini-cli's native ACP mode — exposes a Gemini CLI session on agents.prompt.<machine>.<project>.<session>.
Readme
gemini-nats-channel
NATS channel bridge for gemini-cli's native ACP mode. Exposes a Gemini CLI session as a discoverable, addressable agent on a NATS mesh, implementing the Synadia Agent Protocol for NATS v0.3.
The bridge spawns gemini --acp as a child process and runs an ink-based terminal UI that displays inbound NATS prompts, streams Gemini responses, prompts for permission decisions, and lets the operator type local prompts that flow into the same Gemini session. The wire protocol is bit-compatible with the rest of the Synadia Agent Protocol ecosystem — any caller using @synadia-ai/agents (TypeScript) or synadia-ai-agents (Python) can discover, prompt, and stream from this bridge the same way it can talk to the reference claude-code, pi, or openclaw channels.
Prerequisites
- Bun — the bridge runs on Bun. Install with
curl -fsSL https://bun.sh/install | bashand make surebunis on yourPATH. The package ships TypeScript source directly (no compiled dist). gemini-clionPATH. Override the launch command withGEMINI_ACP_COMMANDif your binary lives elsewhere or you want extra flags (gemini --yolo --acp, etc.).- NATS CLI — for managing contexts and quick wire tests.
- A NATS server reachable from the bridge (defaults to
nats://127.0.0.1:4222; configure via a saved CLI context orNATS_URL). - A Gemini API key — set
GEMINI_API_KEYorGOOGLE_API_KEYin the bridge's env. The bridge forwards both to the spawnedgemini --acpchild verbatim.
Quick Setup
1. Install globally (publishes are under the @agent-ops scope; for local development use bun install --global <path>):
bun add -g @agent-ops/gemini-nats-channel
# or, from a local checkout:
bun install --global /path/to/gemini-nats-channel2. Run interactively (defaults: owner = $USER, session = the current directory's basename, NATS at nats://127.0.0.1:4222):
gemini-nats-channelThe TUI shows a header with the resolved subject + session, a streaming event log, and an input field at the bottom. Type to send a local prompt to the same Gemini session a remote caller would reach over NATS.
3. Or run headless for systemd / tmux deployments — you must declare an explicit permission policy:
gemini-nats-channel --headless --auto-approve-permissions
# or:
gemini-nats-channel --headless --deny-permissionsThe bridge refuses to start in headless mode without one of these flags. There is no silent default.
Protocol compliance
This channel implements the Synadia Agent Protocol for NATS v0.3 end-to-end:
- Registers as an
agentsNATS micro service (§3.1 — the bare subject-safe token). - Service metadata includes
agent: "gemini",owner,session, andprotocol_version: "0.3"(§3.2). promptendpoint declaresmax_payload(advertised in\d+(B|KB|MB|GB)form per §2.1) andattachments_ok: "true". Queue group"agents"(§3.3).- Accepts plain-text shorthand and JSON envelopes with optional base64-encoded attachments (§5.1, §5.2, §5.3). Inbound attachments split:
- Images (
png,jpg,jpeg,gif,webp) — kept in memory as base64 and forwarded as ACPimagecontent blocks. No disk I/O. - Everything else — staged to
<STATE_DIR>/attachments/<request_id>/<filename>and forwarded as ACPresource_linkcontent blocks with afile://URI. Cleaned up when the prompt completes.
- Images (
- Rejects malformed envelopes, empty payloads, oversize requests, and invalid base64 with
Nats-Service-Error-Code: 400(§9). - Emits typed response chunks
{"type":"response","data":"..."}(§6.3) terminated by an empty headerless message (§6.5). Large responses are split into UTF-8-safe slices that each fit under the advertisedmax_payload. - Publishes heartbeats at
agents.hb.<machine>.<project>.<session>with the §8.3 payload includinginstance_id(§8). statusendpoint atagents.status.<machine>.<project>.<session>replies with the same payload as a heartbeat (§8.7).- Permission requests from
gemini-cliare surfaced according to mode:- TUI mode (default): interactive
y/nprompt rendered in the bridge's terminal UI. - Headless mode: every request resolves to the policy declared via
--auto-approve-permissionsor--deny-permissionsand logs to stderr.
- TUI mode (default): interactive
v0.4 (additive — both versions served on the same registration)
This channel also advertises A2A protocol v0.4 capability via the shared @sesh-channels/sdk wrapper. The v0.3 path above is untouched — v0.4 is purely additive:
- Service metadata adds
sesh.protocol_version: "0.4"andsesh.v04_capabilities: "messages,artifacts,cards"alongside the v0.3protocol_version: "0.3". The sesh shim reads these to decide whether to publish v2 envelopes to this adapter. - The prompt endpoint listens on
agents.prompt.<machine>.<project>.<session>, registered via the SDK'sAgentServiceOptions.extraEndpointsmechanism.machine/project/sessioncome from$SESH_MACHINE/$SESH_PROJECT/$SESH_SESSION(sanitized for subject safety); defaults areos.hostname()and the working-directory basename if those env vars are unset.$SESH_ROLEis advertised asmetadata.role, not as a subject token. - v2 envelopes are JSON Messages (per the A2A v0.4 design) — text parts are flattened and forwarded into the same ACP pipeline as v1, so streaming, permission prompts, and the TUI all behave identically. Response chunks stream back via the standard SDK encoder; the v2 reply terminates with an empty headerless message.
- An L3 AgentCard is registered on
agents.card.<machine>.<session>.<name>advertising one skill (gemini.code). The card is byte-identical on every request; the shim merges this L3 contribution with its L1+L2 defaults before signing and serving over HTTPS. - The ACP runtime is opaque to v0.4 wiring (parent plan open question #4): the SDK only touches NATS subjects + metadata, so gemini's ACP-vs-MCP architecture has no impact on v0.4 parity.
Session names
The prompt subject is agents.prompt.<machine>.<project>.<session>. Heartbeats go to agents.hb.<machine>.<project>.<session> and the status endpoint replies on agents.status.<machine>.<project>.<session>.
- Default: sanitized basename of the working directory the bridge was launched from (e.g.,
my-project). - Canonical override: set
SESH_SESSIONenv var — the sesh contract, populated automatically bysesh up --exec/orch-spawn. Also resolved via the.sesh/sessions/<label>.jsonstate-walk when unset. - Other overrides (in precedence order): pass
--session <name>on the CLI (wins over everything), setGEMINI_SESSION_NAMEenv (legacy, kept for back-compat), or persistsessionNameinconfig.json. - Multiple sessions: if the resolved name is already taken by another
geminiinstance owned by the same user, the bridge auto-appends-2,-3, etc.
Discover running sessions via the protocol's discovery subjects:
nats req '$SRV.INFO.agents' '' --replies=0 --timeout=2s
nats req '$SRV.PING.agents' '' --replies=0 --timeout=2s
nats micro ls
nats micro info agentsTalking to the running bridge
Plain text prompt:
nats req agents.prompt."$SESH_MACHINE".my-project.my-session \
--replies=0 --reply-timeout=30s --timeout=5m \
"explain the README in three sentences"With the @synadia-ai/agents TypeScript SDK (or its @agent-ops equivalent in this repo's vendored copy):
import { connect } from "@nats-io/transport-node";
import { Agents } from "@synadia-ai/agents";
const nc = await connect({ servers: "nats://localhost:4222" });
const agents = new Agents({ nc });
const gemini = (await agents.discover()).find((a) => a.agent === "gemini");
for await (const msg of await gemini!.prompt("hello gemini")) {
if (msg.type === "response") process.stdout.write(msg.text);
}
await agents.close();
await nc.close();Permissions
The bridge does not see your code or files directly — gemini-cli does. When Gemini wants to invoke a tool (read a file, execute a command, etc.), it sends an ACP session/request_permission to the bridge. The bridge surfaces this differently depending on mode:
| Mode | Behavior |
| --- | --- |
| TUI (default — operator sits in front of the terminal) | Interactive y / n prompt in the bridge UI. y selects the first allow_once option from the ACP request (falls back to allow_always, then first option); n returns cancelled. |
| --headless --auto-approve-permissions | Every request resolves to the first allow option. Use only when the harness's own auto-approve (gemini --yolo) and your environment are sufficiently sandboxed. |
| --headless --deny-permissions | Every request resolves to cancelled. Suitable when you've pre-configured gemini-cli to skip the permission prompts entirely (e.g., via --yolo), so this path is just a defensive backstop. |
The bridge refuses to start in headless mode without an explicit policy flag — there is no silent default. This is deliberate: an unattended Gemini session deciding what to do without your guidance is exactly the situation worth being explicit about.
Configuration
State lives in ~/.gemini/channels/nats/ (override with GEMINI_STATE_DIR):
| File / dir | Purpose |
| --- | --- |
| config.json | Persisted NATS context, owner, session-name override |
| attachments/<request_id>/ | Per-request staged non-image attachments; auto-cleaned on prompt completion |
NATS CLI contexts live in ~/.config/nats/context/<name>.json — listed with nats context ls.
config.json
{
"context": "my-nats-context",
"owner": "alice",
"sessionName": "demo"
}| Field | Purpose |
| --- | --- |
| context | NATS CLI context name passed to the bridge's connect resolver. |
| owner | Default 4th subject token. Useful for headless / systemd where $USER may be nobody. CLI flag / env var override. |
| sessionName | Default 5th subject token. CLI flag / env var override. |
CLI flags
| Flag | Notes |
| --- | --- |
| --owner <name> | Override the 4th subject token. |
| --session <name> | Override the 5th subject token. |
| --cwd <path> | Working directory passed to the gemini --acp child. |
| --nats-url <url> | Direct NATS URL. Highest precedence. |
| --nats-context <name> | Saved NATS CLI context name. |
| --headless | Disable the TUI; logs to stderr. Requires one of the next two flags. |
| --auto-approve-permissions | Headless: resolve every permission request as "allow". |
| --deny-permissions | Headless: resolve every permission request as "deny". |
Environment variables
| Variable | Overrides | Default |
| --- | --- | --- |
| GEMINI_OWNER | Owner | $USER (or config owner) |
| SESH_SESSION | Session name; canonical sesh contract — wins over GEMINI_SESSION_NAME. Also resolved via .sesh/sessions/<label>.json state-walk when unset. | (unset) |
| GEMINI_SESSION_NAME | Legacy operator override for session name. | sanitized basename of CWD |
| GEMINI_CWD | ACP child's working directory | ${TMPDIR}/gemini-agent/<session>/ |
| GEMINI_ACP_COMMAND | ACP child launch command | gemini --acp |
| GEMINI_STATE_DIR | Bridge state dir | ~/.gemini/channels/nats |
| NATS_URL | NATS connection URL | nats://127.0.0.1:4222 |
| GEMINI_API_KEY / GOOGLE_API_KEY | Forwarded to gemini --acp | (whatever you set; both honored by the harness) |
| SESH_ROLE | Free-form role token (^[a-z0-9_-]+$, 1–63 chars). Identifies the function this agent plays in the swarm — e.g. implementer, verifier, spy. Surfaced as metadata.role on the NATS Micro service and as role in sesh's session manifest. | worker |
| SESH_CLASS | One of active or observer. Coordination-subject routing keys on this: active agents subscribe to workers.*, observer agents subscribe to spies.*. | active |
Working directories
Three distinct concepts — don't conflate them:
| Concept | Default | Used for |
| --- | --- | --- |
| Bridge process CWD | wherever you launched gemini-nats-channel from | Determines the default session name (basename of this directory). |
| ACP child CWD (--cwd / GEMINI_CWD) | ${TMPDIR}/gemini-agent/<session>/ | Working directory passed to gemini --acp. Where Gemini reads / writes files via its tools. |
| State directory (GEMINI_STATE_DIR) | ~/.gemini/channels/nats/ | Per-user config + attachment staging. |
Troubleshooting
spawn gemini ENOENT— installgemini-cli(npm install -g @google/gemini-clior equivalent) sogeminiis on the bridge'sPATH, or setGEMINI_ACP_COMMANDto a locally installed binary.GEMINI_API_KEY requiredin the child's stderr — the bridge forwardsGEMINI_API_KEY/GOOGLE_API_KEYfrom its own environment. Export one before launching the bridge.- No response chunks — confirm the harness spawned (look for
acp-client: initializedandsession readylines on stderr).gemini-cliwrites its own diagnostics to stderr; the bridge mirrors them. connection refusedat startup — the bridge tried to connect to NATS and got nothing. Confirm a NATS server is up (nats server ping) or point the bridge elsewhere via--nats-url/--nats-context.--headless requires an explicit permission policy— pass--auto-approve-permissionsor--deny-permissions. The bridge will not run headless with an implicit policy.- Multiple instances colliding on the same
(owner, session)subject — they don't, automatically. The bridge polls$SRV.INFO.agentsat startup and auto-suffixes the session name with-2,-3, … so two bridges in the same working directory get distinct subjects.
License
Apache-2.0.
