feat: add Google Chat bridge for forever mode

- Minimal Express bridge that receives Google Chat webhooks, forwards
  to the forever agent's external listener via JSON-RPC, and pushes
  responses back via Chat API
- Chat API client for async message delivery (Google Chat webhooks
  have a 30s timeout, agent responses take longer)
- JWT verification for Google Chat requests (skippable for local dev)
- External listener default port changed to 3100 (configurable via
  A2A_PORT env var), blocking timeout increased to 30min
- Startup script (scripts/start-forever.sh) launches both processes
- FOREVER.md with full setup instructions (local dev + GCE VM)
This commit is contained in:
Adam Weidman
2026-03-03 15:26:31 -05:00
parent 89d62f387f
commit 8caf7d5690
8 changed files with 739 additions and 2 deletions
+255
View File
@@ -0,0 +1,255 @@
# Forever Agent + Google Chat Bridge
Run Gemini CLI as an autonomous forever agent, accessible via Google Chat.
## Architecture
```
Google Chat Space
↓ webhook (HTTPS)
Chat Bridge (port 8081, public)
↓ JSON-RPC (localhost)
gemini-cli --forever (port 3100, localhost only)
Gemini API (LLM + tools)
```
**Two processes on one VM:**
- **gemini-cli --forever** — the agent, runs continuously with Sisyphus
auto-resume
- **Chat bridge** — receives Google Chat webhooks, forwards to agent, pushes
responses back via Chat API
One agent per Google Chat space. YOLO mode (auto-approve all tools).
## Prerequisites
1. **Google Cloud project** with:
- Chat API enabled
- A service account with `Chat Bot` role
- Service account key JSON file
2. **Gemini API key** from
[Google AI Studio](https://aistudio.google.com/apikey)
3. **Node.js 20+**
## Local Development (with ngrok)
### 1. Install and build
```bash
git clone -b st/forever https://github.com/google-gemini/gemini-cli.git gemini-cli-forever
cd gemini-cli-forever
npm install --ignore-scripts
npm run build
```
### 2. Set env vars
```bash
export GOOGLE_API_KEY="your-gemini-api-key"
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json"
export A2A_PORT=3100
export BRIDGE_PORT=8081
# Optional: set for JWT verification (your GCP project number, not project ID)
# export CHAT_PROJECT_NUMBER="123456789"
```
### 3. Start ngrok (separate terminal)
```bash
ngrok http 8081
```
Copy the HTTPS URL (e.g., `https://abc123.ngrok.io`).
### 4. Configure Google Chat App
1. Go to
[Google Cloud Console → APIs & Services → Google Chat API](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat)
2. Click **Configuration**
3. Set:
- **App name:** Forever Agent
- **App URL:** `https://abc123.ngrok.io/chat/webhook`
- **Visibility:** People and groups in your domain (or specific people)
- **Functionality:** Spaces and group conversations (check), Direct messages
(check)
- **Connection settings:** HTTP endpoint URL
- **Permissions:** Everyone in your Workspace domain (or specific people)
4. Save
### 5. Start the agent
```bash
./scripts/start-forever.sh
```
The onboarding dialog will ask for a mission and Sisyphus config on first run.
### 6. Test
1. Open Google Chat
2. Create a new space and add the "Forever Agent" app
3. Send a message — the agent will process it and respond
## GCE VM Deployment
### 1. Create the VM
```bash
gcloud compute instances create forever-agent \
--zone=us-central1-a \
--machine-type=e2-small \
--image-family=debian-12 \
--image-project=debian-cloud \
--boot-disk-size=20GB \
--tags=forever-agent
```
### 2. Allow inbound traffic on bridge port
```bash
gcloud compute firewall-rules create allow-chat-bridge \
--allow=tcp:8081 \
--target-tags=forever-agent \
--source-ranges=0.0.0.0/0 \
--description="Allow Google Chat webhooks to reach the bridge"
```
### 3. SSH and install Node.js
```bash
gcloud compute ssh forever-agent --zone=us-central1-a
# On the VM:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs git
```
### 4. Clone and build
```bash
git clone -b st/forever https://github.com/google-gemini/gemini-cli.git
cd gemini-cli
npm install --ignore-scripts
npm run build
```
### 5. Configure
```bash
# Create env file
cat > ~/.forever-agent.env << 'EOF'
GOOGLE_API_KEY=your-gemini-api-key
GOOGLE_APPLICATION_CREDENTIALS=/home/$USER/service-account-key.json
A2A_PORT=3100
BRIDGE_PORT=8081
CHAT_PROJECT_NUMBER=your-project-number
EOF
# Upload service account key
# (from your local machine):
# gcloud compute scp service-account-key.json forever-agent:~/service-account-key.json --zone=us-central1-a
```
### 6. Create systemd service
```bash
sudo tee /etc/systemd/system/forever-agent.service << EOF
[Unit]
Description=Gemini CLI Forever Agent
After=network.target
[Service]
Type=simple
User=$USER
WorkingDirectory=/home/$USER/gemini-cli
EnvironmentFile=/home/$USER/.forever-agent.env
ExecStart=/home/$USER/gemini-cli/scripts/start-forever.sh
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable forever-agent
sudo systemctl start forever-agent
```
### 7. Check logs
```bash
sudo journalctl -u forever-agent -f
```
### 8. Configure Google Chat App
Same as local dev, but use the VM's external IP:
- **App URL:** `http://EXTERNAL_IP:8081/chat/webhook`
> **Note:** Google Chat requires HTTPS in production. For HTTPS, either:
>
> - Put nginx + Let's Encrypt in front
> (`sudo apt install nginx certbot python3-certbot-nginx`)
> - Use a Cloud Load Balancer with managed cert
> - For testing, HTTP works with some Chat API configurations
## Env Vars Reference
| Variable | Required | Default | Description |
| -------------------------------- | -------- | ----------------------- | --------------------------------------- |
| `GOOGLE_API_KEY` | Yes | — | Gemini API key |
| `GOOGLE_APPLICATION_CREDENTIALS` | Yes | — | Path to service account key JSON |
| `A2A_PORT` | No | `3100` | External listener port (agent) |
| `BRIDGE_PORT` | No | `8081` | Chat bridge port (public-facing) |
| `A2A_URL` | No | `http://127.0.0.1:3100` | Agent URL (for bridge to connect to) |
| `CHAT_PROJECT_NUMBER` | No | — | GCP project number for JWT verification |
## How It Works
1. User sends message in Google Chat space
2. Google Chat POSTs webhook to the bridge (`/chat/webhook`)
3. Bridge immediately returns `{}` (Google Chat has a 30s webhook timeout)
4. Bridge asynchronously sends the message to the forever agent via JSON-RPC
5. Agent processes the message (may take minutes — tools, thinking, etc.)
6. Agent returns response to bridge
7. Bridge pushes response to Google Chat via Chat REST API
The forever agent runs with:
- **Sisyphus** — auto-resumes after idle timeout (configurable)
- **Confucius** — reflects and consolidates knowledge at ~80% context
- **Hippocampus** — extracts key facts after each turn
- **Bicameral Voice** — proactively captures knowledge from user messages
- **YOLO mode** — auto-approves all tool calls (no human-in-the-loop)
## Troubleshooting
### Bridge returns 401/403
- Check `CHAT_PROJECT_NUMBER` matches your GCP project number (not project ID)
- Unset `CHAT_PROJECT_NUMBER` to disable JWT verification for testing
### Agent doesn't respond
- Check the agent is running:
`curl http://localhost:3100/.well-known/agent-card.json`
- Check bridge health: `curl http://localhost:8081/health`
- Check bridge logs for errors
### Google Chat shows no response
- The bridge returns `{}` immediately — responses are pushed async via Chat API
- Ensure `GOOGLE_APPLICATION_CREDENTIALS` points to a valid service account key
- Ensure the service account has `Chat Bot` role
### Webhook not reaching bridge
- Check firewall rules allow inbound on port 8081
- For HTTPS requirement: use ngrok for testing, nginx + Let's Encrypt for
production
+1
View File
@@ -17086,6 +17086,7 @@
"@google/gemini-cli-core": "file:../core",
"express": "^5.1.0",
"fs-extra": "^11.3.0",
"google-auth-library": "^9.15.1",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.8",
"uuid": "^13.0.0",
+1
View File
@@ -29,6 +29,7 @@
"@google-cloud/storage": "^7.16.0",
"@google/gemini-cli-core": "file:../core",
"express": "^5.1.0",
"google-auth-library": "^9.15.1",
"fs-extra": "^11.3.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.8",
@@ -0,0 +1,321 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Minimal Google Chat bridge for the Gemini CLI forever mode.
*
* Architecture:
* Google Chat webhook -> this bridge (port 8081) -> external listener (port 3100)
* Response comes back -> bridge pushes to Google Chat via Chat API
*
* One agent per space. Messages are forwarded as-is to the running
* gemini-cli --forever session via its JSON-RPC external listener.
*/
import express from 'express';
import { OAuth2Client } from 'google-auth-library';
import { ChatApiClient } from './chat-api-client.js';
import { logger } from '../utils/logger.js';
// --- Config from env vars ---
const BRIDGE_PORT = parseInt(process.env['BRIDGE_PORT'] ?? '8081', 10);
const A2A_URL = process.env['A2A_URL'] ?? 'http://127.0.0.1:3100';
const CHAT_PROJECT_NUMBER = process.env['CHAT_PROJECT_NUMBER'];
const CHAT_ISSUER = 'chat@system.gserviceaccount.com';
// --- Types ---
interface ChatMessagePart {
kind?: string;
text?: string;
}
interface JsonRpcResponse {
jsonrpc: string;
id: string | number | null;
result?: {
kind: string;
id: string;
status: {
state: string;
message?: {
parts: ChatMessagePart[];
};
};
};
error?: { code: number; message: string };
}
// --- Auth middleware ---
function createAuthMiddleware(): (
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => void {
// On Cloud Run, IAM handles auth
if (process.env['K_SERVICE']) {
logger.info('[Bridge] Running on Cloud Run — auth delegated to IAM.');
return (_req, _res, next) => next();
}
if (!CHAT_PROJECT_NUMBER) {
logger.warn(
'[Bridge] CHAT_PROJECT_NUMBER not set — JWT verification disabled.',
);
return (_req, _res, next) => next();
}
const authClient = new OAuth2Client();
return (req, res, next) => {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
authClient
.verifyIdToken({
idToken: authHeader.substring(7),
audience: CHAT_PROJECT_NUMBER,
})
.then((ticket) => {
const payload = ticket.getPayload();
if (payload?.iss !== CHAT_ISSUER) {
res.status(403).json({ error: 'Forbidden: invalid issuer' });
return;
}
next();
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : 'Unknown error';
logger.warn(`[Bridge] JWT verification failed: ${msg}`);
res.status(401).json({ error: 'Unauthorized' });
});
};
}
// --- Event normalization ---
function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function str(obj: Record<string, unknown>, key: string): string {
const v = obj[key];
return typeof v === 'string' ? v : '';
}
interface NormalizedEvent {
type: string;
text: string;
spaceName: string;
threadName: string;
}
/**
* Extract the essentials from a Google Chat webhook event.
* Handles both legacy and Workspace Add-ons format.
*/
function normalizeEvent(raw: Record<string, unknown>): NormalizedEvent | null {
// Legacy format
if (typeof raw['type'] === 'string') {
const message = isObj(raw['message']) ? raw['message'] : {};
const space = isObj(raw['space'])
? raw['space']
: isObj(message['space'])
? message['space']
: {};
const thread = isObj(message['thread']) ? message['thread'] : {};
return {
type: raw['type'],
text: str(message, 'text'),
spaceName: str(space, 'name'),
threadName: str(thread, 'name'),
};
}
// Workspace Add-ons format
const chat = raw['chat'];
if (!isObj(chat)) return null;
if (isObj(chat['messagePayload'])) {
const payload = chat['messagePayload'];
const message = isObj(payload['message']) ? payload['message'] : {};
const space = isObj(payload['space'])
? payload['space']
: isObj(message['space'])
? message['space']
: {};
const thread = isObj(message['thread']) ? message['thread'] : {};
return {
type: 'MESSAGE',
text: str(message, 'text'),
spaceName: str(space, 'name'),
threadName: str(thread, 'name'),
};
}
if (isObj(chat['addedToSpacePayload'])) {
const payload = chat['addedToSpacePayload'];
const space = isObj(payload['space']) ? payload['space'] : {};
return {
type: 'ADDED_TO_SPACE',
text: '',
spaceName: str(space, 'name'),
threadName: '',
};
}
return null;
}
// --- JSON-RPC call to external listener ---
async function sendToAgent(text: string): Promise<string> {
const body = JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method: 'message/send',
params: {
message: {
role: 'user',
parts: [{ kind: 'text', text }],
},
},
});
const response = await fetch(A2A_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
if (!response.ok) {
throw new Error(
`Agent returned ${response.status}: ${await response.text()}`,
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = (await response.json()) as JsonRpcResponse;
if (result.error) {
throw new Error(`Agent error: ${result.error.message}`);
}
// Extract text from the response
const parts = result.result?.status?.message?.parts ?? [];
const texts = parts
.filter((p) => p.kind === 'text' && p.text)
.map((p) => p.text!);
return texts.join('\n') || '(no response)';
}
// --- Express app ---
export function createBridgeApp(): express.Express {
const app = express();
app.use(express.json());
const chatApi = new ChatApiClient();
const auth = createAuthMiddleware();
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', a2aUrl: A2A_URL });
});
// Google Chat webhook
app.post('/chat/webhook', auth, (req, res) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const raw = req.body as Record<string, unknown>;
const event = normalizeEvent(raw);
if (!event) {
logger.warn(
`[Bridge] Unknown event format: ${Object.keys(raw).join(',')}`,
);
res.json({});
return;
}
logger.info(
`[Bridge] ${event.type}: space=${event.spaceName} text="${event.text.substring(0, 100)}"`,
);
// Handle non-message events
if (event.type === 'ADDED_TO_SPACE') {
res.json({
hostAppDataAction: {
chatDataAction: {
createMessageAction: {
message: {
text: 'Gemini CLI forever agent connected. Send me a task!',
},
},
},
},
});
return;
}
if (event.type !== 'MESSAGE' || !event.text) {
res.json({});
return;
}
// Immediately ack the webhook (30s timeout)
res.json({});
// Process async — send to agent, push response back via Chat API
processMessageAsync(chatApi, event).catch((err) => {
logger.error(`[Bridge] Async processing failed: ${err}`);
});
});
return app;
}
async function processMessageAsync(
chatApi: ChatApiClient,
event: NormalizedEvent,
): Promise<void> {
const { text, spaceName, threadName } = event;
try {
logger.info(`[Bridge] Sending to agent: "${text.substring(0, 100)}"`);
const responseText = await sendToAgent(text);
logger.info(
`[Bridge] Agent response (${responseText.length} chars): "${responseText.substring(0, 100)}..."`,
);
// Push response back to Google Chat
await chatApi.sendMessage(spaceName, threadName, { text: responseText });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.error(`[Bridge] Error: ${msg}`);
await chatApi.sendMessage(spaceName, threadName, {
text: `Error: ${msg}`,
});
}
}
// --- Standalone entrypoint ---
if (
process.argv[1]?.endsWith('bridge.js') ||
process.argv[1]?.endsWith('bridge.ts')
) {
const app = createBridgeApp();
app.listen(BRIDGE_PORT, '0.0.0.0', () => {
logger.info(`[Bridge] Google Chat bridge listening on port ${BRIDGE_PORT}`);
logger.info(`[Bridge] Forwarding to agent at ${A2A_URL}`);
});
}
@@ -0,0 +1,105 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Google Chat REST API client for pushing messages to spaces.
*/
import { GoogleAuth } from 'google-auth-library';
import { logger } from '../utils/logger.js';
const CHAT_API_BASE = 'https://chat.googleapis.com/v1';
const MAX_TEXT_LENGTH = 4000;
export class ChatApiClient {
private auth: GoogleAuth;
private initialized = false;
constructor() {
this.auth = new GoogleAuth({
scopes: ['https://www.googleapis.com/auth/chat.bot'],
});
}
private async init(): Promise<void> {
if (this.initialized) return;
await this.auth.getClient();
this.initialized = true;
logger.info('[ChatApi] Initialized');
}
async sendMessage(
spaceName: string,
threadName: string,
options: { text?: string },
): Promise<void> {
await this.init();
const chunks = options.text ? splitText(options.text) : [''];
for (const chunk of chunks) {
const message: Record<string, unknown> = {};
if (chunk) message['text'] = chunk;
if (threadName) {
message['thread'] = { name: threadName };
}
await this.postMessage(spaceName, message);
}
}
private async postMessage(
spaceName: string,
message: Record<string, unknown>,
): Promise<void> {
try {
const url =
`${CHAT_API_BASE}/${spaceName}/messages` +
`?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`;
const client = await this.auth.getClient();
const headers = await client.getRequestHeaders();
const response = await fetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(message),
});
if (!response.ok) {
const body = await response.text();
logger.error(`[ChatApi] Send failed: ${response.status} ${body}`);
} else {
logger.info(`[ChatApi] Message sent to ${spaceName}`);
}
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
logger.error(`[ChatApi] Error: ${msg}`);
}
}
}
function splitText(text: string): string[] {
if (text.length <= MAX_TEXT_LENGTH) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > MAX_TEXT_LENGTH) {
let splitAt = remaining.lastIndexOf('\n\n', MAX_TEXT_LENGTH);
if (splitAt < MAX_TEXT_LENGTH * 0.3) {
splitAt = remaining.lastIndexOf('\n', MAX_TEXT_LENGTH);
}
if (splitAt < MAX_TEXT_LENGTH * 0.3) {
splitAt = MAX_TEXT_LENGTH;
}
chunks.push(remaining.substring(0, splitAt));
remaining = remaining.substring(splitAt);
}
if (remaining) chunks.push(remaining);
return chunks;
}
+2 -1
View File
@@ -413,7 +413,8 @@ export async function parseArguments(
result['a2aPort'] === undefined &&
result['a2a-port'] === undefined
) {
(result as Record<string, unknown>)['a2aPort'] = 0;
(result as Record<string, unknown>)['a2aPort'] =
parseInt(process.env['A2A_PORT'] ?? '0', 10) || 3100;
}
// The import format is now only controlled by settings.memoryImportFormat // We no longer accept it as a CLI argument
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+1 -1
View File
@@ -33,7 +33,7 @@ interface A2ATask {
const tasks = new Map<string, A2ATask>();
const TASK_CLEANUP_DELAY_MS = 10 * 60 * 1000; // 10 minutes
const DEFAULT_BLOCKING_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const DEFAULT_BLOCKING_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes (forever mode tasks can run long)
interface ResponseWaiter {
taskId: string;
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Start the forever agent + chat bridge together.
# Usage: ./scripts/start-forever.sh
#
# Required env vars:
# GOOGLE_API_KEY - Gemini API key
# GOOGLE_APPLICATION_CREDENTIALS - path to service account key (for Chat API)
#
# Optional env vars:
# A2A_PORT - external listener port (default: 3100)
# BRIDGE_PORT - chat bridge port (default: 8081)
# CHAT_PROJECT_NUMBER - Google Cloud project number (for JWT verification)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Default ports
export A2A_PORT="${A2A_PORT:-3100}"
export BRIDGE_PORT="${BRIDGE_PORT:-8081}"
export A2A_URL="${A2A_URL:-http://127.0.0.1:${A2A_PORT}}"
echo "=== Gemini CLI Forever Agent ==="
echo "Agent listener: localhost:${A2A_PORT}"
echo "Chat bridge: 0.0.0.0:${BRIDGE_PORT}"
echo ""
# Build if needed
if [ ! -d "$REPO_ROOT/packages/a2a-server/dist" ]; then
echo "Building a2a-server..."
cd "$REPO_ROOT" && npm run build --workspace=packages/a2a-server
fi
# Start the chat bridge in the background
echo "Starting chat bridge..."
node "$REPO_ROOT/packages/a2a-server/dist/src/chat-bridge/bridge.js" &
BRIDGE_PID=$!
# Cleanup on exit
cleanup() {
echo "Shutting down..."
kill "$BRIDGE_PID" 2>/dev/null || true
wait "$BRIDGE_PID" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# Start the forever agent in the foreground
echo "Starting forever agent..."
cd "$REPO_ROOT"
npx gemini --forever --a2a-port "$A2A_PORT" --yolo
# If the agent exits, cleanup will fire