REST API server that wraps Google's Antigravity CLI (agy) interactive mode, giving you HTTP endpoints for named multi-session chat with full conversation continuity. Each session gets its own live, warm CLI process — run as many parallel conversations as you need, with no per-request startup cost.
# 1. Start the server
docker compose up -d --build
# 2. First time only — authenticate inside the container
docker exec -it gemini-cli-rest-agy-rest-1 agy
# Complete auth in browser, then Ctrl+C to exit
# 3. Restart so the server picks up the auth
docker compose restart
# 4. Chat! (session "default" is created automatically)
curl -s -X POST http://localhost:8000/chat/default \
-H "Content-Type: application/json" \
-d '{"prompt": "What is 2+2?"}'Auth persists across restarts (stored in a Docker named volume).
Creates the session on first use. Follow-up messages retain full conversation context.
curl -s -X POST http://localhost:8000/chat/research \
-H "Content-Type: application/json" \
-d '{"prompt": "Explain the CAP theorem in 2 sentences"}'Response:
{
"response": "The CAP theorem states that a distributed system can only simultaneously provide two out of three guarantees: Consistency, Availability, and Partition Tolerance.",
"session": "research",
"turn": 1,
"elapsed_ms": 9573,
"via": "bell"
}Follow-up in the same session:
curl -s -X POST http://localhost:8000/chat/research \
-H "Content-Type: application/json" \
-d '{"prompt": "Now compare it to the PACELC theorem"}'via says how the end of the turn was detected: "bell" (agy's terminal-bell notification — the fast path; the codex bridge reports "notify"), or one of the polling-fallback exit reasons ("transcript_done" / "rollout_done", "stalled", "hard_timeout"). A missed notification only means a slower answer, never a hung one — and the field makes the fallback rate observable.
Meanwhile, a completely separate conversation:
curl -s -X POST http://localhost:8000/chat/coding \
-H "Content-Type: application/json" \
-d '{"prompt": "Write a Python fibonacci generator"}'Returns a session's most recent completed answer without re-asking — for when a POST /chat response never reached you (the 3-min hard cap, or a connectivity blip after the CLI had already answered). The answer is durable in the CLI's own transcript/rollout regardless of whether the HTTP response arrived, so /last simply reads it back.
It is binary by design: you get the answer only once the turn is done, never a partial and never a previous turn's answer in its place.
# instant snapshot
curl -s http://localhost:8000/last/research
# or wait up to 30s for an in-flight turn to finish, then return it
curl -s "http://localhost:8000/last/research?wait=30"Response when the turn has finished:
{ "done": true, "response": "…the answer…", "turn": 2, "session": "research", "elapsed_ms": 12, "status": "done" }While the turn is still running — poll again:
{ "done": false, "response": null, "turn": 2, "session": "research", "elapsed_ms": 30001, "status": "pending" }status tells the two not-done cases apart: "pending" = still working, keep polling; "never_started" = the CLI dropped the prompt and no answer is coming — re-send it. (The codex bridge adds "usage_limit" / "model_drift" / "error": done, but with an empty response and an error message — see CODEX.md.)
wait is capped at LAST_MAX_WAIT (default 180s) so /last never blocks longer than a /chat would. Recovery works while the server is up (the warm process holds the session); it does not survive a full server restart.
Wipes the conversation by respawning agy in a fresh project directory (~5s). A respawn is required because agy keeps cross-conversation memory per project: its own /clear starts a new conversation that still receives summaries of the previous ones — and the agent can read their transcripts to recover "cleared" context.
curl -s -X POST http://localhost:8000/clear/researchKills the session's agy process and spawns a new one. Use when the CLI is stuck or misbehaving (~25s).
curl -s -X POST http://localhost:8000/reset/researchKills and permanently removes a session and its process.
curl -s -X DELETE http://localhost:8000/chat/researchKills every active session. Clean slate.
curl -s -X POST http://localhost:8000/stopLists all active sessions and their status. docker-compose.yml also uses it as
the container healthcheck (both bridges must answer, so docker ps shows
healthy/unhealthy for the pair).
curl -s http://localhost:8000/health # agy
curl -s http://localhost:8001/health # codex{
"status": "ok",
"active_sessions": 2,
"sessions": [
{"name": "research", "alive": true, "turn_count": 3},
{"name": "coding", "alive": true, "turn_count": 1}
]
}- Parallel research — Run multiple topic-specific sessions simultaneously (
/chat/ml-papers,/chat/api-docs,/chat/competitor-analysis) - Automation scripts — Each script gets its own named session with isolated context, no cross-contamination
- Agent contexts — Give each autonomous agent a dedicated session (
/chat/agent-planner,/chat/agent-coder,/chat/agent-reviewer) - Interactive + batch — Keep a long-running exploratory session open while firing off one-shot queries in disposable sessions
docker compose up -d --build # Build and start
docker compose down # Stop
docker compose restart # Restart (keeps auth)
docker compose logs -f # View live logs (stdout)Both bridges write persistent log files in addition to stdout, plus a
per-incident dump for every slow or failed turn — so you can find out why
a turn was slow or timed out without docker exec'ing into a live container.
Everything lands under LOG_DIR (/app/logs), which docker-compose mounts to
./logs on the host:
./logs/
├── agy-rest.log # rolling agy bridge log (10MB x 5)
├── codex-rest.log # rolling codex bridge log
└── timeouts/ # one file per slow/failed turn — START HERE
├── <session>-turn<N>-<conversation-id>.log # agy
└── codex-<session>-turn<N>-<rollout>.log # codex
Per-request access log. Every line is tagged with its bridge ([gemini]
or [codex], worktree lines included) so the shared docker logs stream stays
attributable; the rolling *-rest.log files use the same format with a full
date. Each request logs one line when it arrives and one when it
finishes, sharing a short id; /chat also records who asked what:
09:34:25 [gemini] INFO --> POST /chat/foo@dev [a1b2c3d4] from 172.20.0.1
09:34:25 [gemini] INFO [a1b2c3d4] /chat session 'foo@dev' ua='curl/8.18.0' prompt (33 chars): 'Reply with…'
09:34:38 [gemini] INFO <-- POST /chat/foo@dev [a1b2c3d4] 200 in 12.8s # finished: status + duration
09:34:38 [gemini] INFO [b5c6d7e8] DELETE /chat/nope@dev failed 404: Session 'nope@dev' not found.
09:34:38 [gemini] INFO <-- DELETE /chat/nope@dev [b5c6d7e8] 404 in 0.0s
This is how you investigate the three failure modes from the file alone:
a stuck request shows a --> with no matching <-- (still hung in its
handler); a break logs a full !!! traceback and returns 500; a slow
turn is the one whose <-- carries a large in …s. Any request the bridge
fails on purpose logs its reason on a failed NNN: line just before the <--
(WARNING for 5xx, INFO for 4xx and malformed bodies). /last additionally logs
whether it actually recovered a completed answer. Health checks log at DEBUG
so polling never floods the file, and uvicorn's own access log is off — the
bridge lines above replace it.
When does a dump get written? Whenever a turn hits the hard cap, stalls, or
simply runs slower than *_SLOW_DUMP_SECS (90s) — even if it succeeded.
A turn faster than that leaves no dump.
What's in each dump (both bridges follow the same shape now):
- agy — the resolved conversation id + transcript path, the timestamped
tail of the transcript (the step-by-step record — where the time actually
went, e.g. a long
RUN_COMMANDor anode: command not foundretry loop), and the rendered screen at the moment the bridge stopped waiting. That screen tells you whether a missed answer was already on-screen (transcript flush lag) or agy was stillGenerating.... - codex — the resolved session id + rollout path, the tail of the rollout
events (
task_started/response_item/task_completewith theirturn_ids — the turn-by-turn record), and the rendered screen at give-up time. Full detail lives in codex's own rollout at~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl.
Turn timeouts. A single request is hard-capped at 3 minutes
(RESPONSE_HARD_TIMEOUT / CODEX_RESPONSE_HARD_TIMEOUT = 180); each bridge also
gives up early if its CLI makes no progress for *_STALL_TIMEOUT (90s). Work
that genuinely needs longer should be handled by the client re-polling, not
by raising these caps. That re-poll is GET /last/{name}:
the turn keeps running in the warm process after a request gives up, its answer
is written to the CLI's transcript/rollout, and /last reads it back once it is
done — so a capped or dropped /chat never loses the reply.
Requires Python 3.11+, tmux, and the Antigravity CLI.
# Install Antigravity CLI (to ~/.local/bin/agy)
curl -fsSL https://antigravity.google/cli/install.sh | bash
agy # authenticate once, then /quit or Ctrl+C
# Install Python deps (tmux from your package manager if missing)
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# Run
uvicorn server:app --host 0.0.0.0 --port 8000All config via environment variables (set in docker-compose.yml or shell):
| Variable | Default | Description |
|---|---|---|
AGY_CMD |
agy |
Path to the agy CLI binary |
AGY_SKIP_PERMISSIONS |
false |
Pass --dangerously-skip-permissions to agy. Prefer "toolPermission": "always-proceed" in agy's settings.json (the Docker image seeds this) |
AGY_EXTRA_ARGS |
Additional CLI args (space-separated) | |
AGY_MODEL |
Gemini 3.8 Flash (High) |
Passed as --model to every session. Reasoning effort is part of the model name in agy (agy models lists Low and High as separate entries), so this is how effort is set. Its own variable, not AGY_EXTRA_ARGS: the name contains spaces. Wins over the model in settings.json, which agy rewrites itself |
AGY_STATE_DIR |
~/.gemini/antigravity-cli |
agy's state directory (conversation transcripts live here) |
SESSIONS_ROOT |
/tmp/agy-rest-sessions |
Per-session working directories |
TMUX_SOCKET |
agy-rest |
Dedicated tmux server socket name |
RESPONSE_POLL_INTERVAL |
0.5 |
Seconds between completion-detection polls (fallback path) |
AGY_BELL |
1 |
Use agy's end-of-turn terminal bell as the fast done-signal; set to 0 to disable and revert to pure polling |
AGY_BELL_DIR |
/tmp/agy-rest-bells |
Directory where the tmux alert-bell hook records bells (one file per tmux session) |
RESPONSE_FAST_POLL |
0.3 |
Seconds between checks of the bell file while a turn is in flight |
RESPONSE_FULL_CHECK_EVERY |
10 |
Run the full fallback poll (transcript + screen) every Nth bell-check wake (~3s) |
RESPONSE_STALL_TIMEOUT |
90 |
Give up if agy makes no progress (idle, transcript not growing) for this long |
RESPONSE_HARD_TIMEOUT |
180 |
Absolute hard cap on a turn (3 min), regardless of progress |
RESPONSE_SLOW_DUMP_SECS |
90 |
Write a diagnostic dump for any turn slower than this (even successful ones) |
STARTUP_TIMEOUT |
60 |
Max seconds to wait for CLI startup |
AGY_STARTUP_RETRIES |
1 |
Respawns after a startup that never shows the idle prompt (screen + agy's own process log are dumped to LOG_DIR/timeouts/<session>-startup-*.log first, the stuck process is killed and confirmed gone, then the same worktree/tmux session is relaunched). Only after every attempt fails does /chat get the 503 "agy startup timed out … (after N attempts)". Startups are serialized bridge-wide: agy shares a per-second process log, sqlite stores and presence locks under AGY_STATE_DIR, and two instances initializing at once (or one starting while another is still shutting down) is what stalled it |
AGY_EXIT_WAIT |
10 |
Seconds a stop/clear/reset waits for the agy process to actually exit after tmux kill-session (its shutdown takes 0.1–5s: it waits for store migrations first) before SIGKILLing its process group. A spawn never overlaps a shutting-down instance |
VERIFY_RESUBMIT_MAX |
3 |
How many times a session's first prompt is re-pasted when agy's per-launch account-verification gate eats it (⚠ Verifying your account...); set to 0 to disable the recovery |
VERIFY_RESUBMIT_DELAY |
3.0 |
Seconds to let the screen settle before a re-paste — and the grace a fresh submit gets before the (permanently displayed) notice may count as another drop |
CONVERSATION_DETECT_TIMEOUT |
20 |
Expected time for a session's first prompt to produce its conversation transcript (how the bridge learns the conversation id). An idle screen with no transcript at the end of it means the prompt was dropped → /chat 502 |
CONVERSATION_DETECT_MAX |
RESPONSE_STALL_TIMEOUT (90) |
Hard bound on that wait when agy is visibly still working past the window — process alive and the screen showing Generating... or the auth/backend Signing in... spinner (agy 1.1.27 can sit 20s+ on a post-login loadCodeAssist call before forwarding the prompt). Checked ~1/s; a give-up writes LOG_DIR/timeouts/<session>-detect-*.log (screen + tail of agy's own log). Response collection then gets only what is left of RESPONSE_HARD_TIMEOUT |
SUBMIT_REPASTE_MAX |
2 |
How many times a submit may be re-pasted when agy consumed the paste itself (input box empty, transcript frozen — Enter re-press can't help). Re-checked against the transcript at the last instant so an accepted turn can never duplicate; 0 disables |
SUBMIT_REPASTE_DELAY |
3.0 |
Seconds to let the screen settle before each re-paste |
LOG_DIR |
/app/logs |
Rolling logs + per-incident dumps written here (mounted to ./logs) |
LOG_LEVEL |
INFO |
Logging level |
WORKTREE_GIT_TIMEOUT |
60 |
Seconds before any git call made for a session worktree (above all the spawn-time git fetch) is killed. A timed-out or failed fetch is logged as a warning and the spawn continues on the origin/* refs already in the clone; 0 disables the cap. Pair with a GIT_SSH_COMMAND carrying -o ConnectTimeout / -o BatchMode=yes (compose does) so ssh itself cannot prompt or hang |
WORKTREE_FETCH_MIN_INTERVAL |
60 |
At most one git fetch per this many seconds per bridge process; concurrent spawns share the in-flight fetch, later ones inside the window skip it (agy and codex are separate processes, so the pair may fetch twice per window) |
The codex bridge (port 8001) shares this tmux architecture and has matching, CODEX_-prefixed knobs — CODEX_RESPONSE_HARD_TIMEOUT (180), CODEX_RESPONSE_STALL_TIMEOUT (90), CODEX_STARTUP_TIMEOUT (60), CODEX_SLOW_DUMP_SECS (90), CODEX_TMUX_SOCKET (codex-rest) — and shares LOG_DIR / LOG_LEVEL. Its completion-push equivalent is codex's notify hook rather than a bell: CODEX_NOTIFY (1, set 0 to revert to pure polling), CODEX_NOTIFY_DIR (/tmp/codex-rest-notify), CODEX_RESPONSE_FAST_POLL (0.3), CODEX_RESPONSE_FULL_CHECK_EVERY (10). See CODEX.md and Logs & diagnostics.
The model is not selected per-request — set "model" in agy's settings.json (~/.gemini/antigravity-cli/settings.json).
Client (curl/app) Server (FastAPI) tmux agy
───────────────── ────────────────────────── ─────────────── ─────────────
ChatManager
┌──────────────────────┐
POST /chat/foo ──────→│ session "foo" │ tmux session
│ AgySession ─────────────→ "agy-foo" ─────→ live agy TUI
│ │ paste-buffer -p → typed input
│ response text ←──────── transcript.jsonl ←── model output
← JSON response ←──│ │ capture-pane → busy/idle check
├──────────────────────┤
POST /chat/bar ──────→│ session "bar" │ tmux session
│ AgySession ─────────────→ "agy-bar" ─────→ live agy TUI
└──────────────────────┘
GET /last/{name} ─→ re-read the last COMPLETED answer (recover a lost response)
POST /clear/{name} → respawn in a fresh project dir (true context wipe)
POST /reset/{name} → kill + respawn session's process
DELETE /chat/{name} → stop + remove session entirely
POST /stop ────────→ stop all sessions
GET /health ──────→ list all sessions + status
Each named session owns a live agy process hosted in a detached tmux session (dedicated socket, so it never touches your own tmux). tmux acts as a terminal-emulator mediator with four clean channels:
- Input — prompts are injected with
tmux load-buffer+paste-buffer -p(bracketed paste), so newlines and TUI shortcut characters (!,@,/, backticks) always arrive as literal text. - Response content — read from agy's structured per-conversation transcript (
brain/<conversation>/.system_generated/logs/transcript.jsonl), not scraped off the screen. The bridge takes the completed (DONE) model steps that appeared after the prompt was sent. - Completion push (bell) — the primary done-signal. With
"notifications": truein agy'ssettings.json(the entrypoint seeds it; the server also ensures it at startup), agy rings a terminal bell at end of turn, 0–43ms after the answer is flushed to the transcript. A server-global tmuxalert-bellhook appends a timestamped line toAGY_BELL_DIR/<tmux-session>; the bridge checks that file everyRESPONSE_FAST_POLL(0.3s) and, on a new bell past the turn's baseline, reads the transcript immediately, with a single busy-glance at the pane confirming the ring wasn't a stray mid-turn bell — no idle-marker match, no debounce./lastalso accepts bell-evidence in place of the idle-screen check. - Busy/idle detection (fallback) —
tmux capture-panereturns the rendered screen (no ANSI escapes); if the bell is missed (orAGY_BELL=0), a turn is complete when a new model step exists in the transcript and the screen shows the idle status bar (? for shortcuts) with noGenerating...indicator. A missed bell degrades to this slower path, never to a hung request.
You can watch any session live while the bridge drives it:
tmux -L agy-rest attach -t agy-research # Ctrl+B then D to detach- No streaming — Responses return only after fully collected.
- Prompts starting with
/may be interpreted as agy slash commands. - Model selection is global (agy
settings.json), not per-session. - agy's brain is shared — sessions are isolated per project directory, but the agent has filesystem access; a prompt that explicitly asks it to read another conversation's transcript under
~/.gemini/antigravity-cli/brain/could cross session boundaries.