diff --git a/dashboard/app.py b/dashboard/app.py
index d5ccde7..a5557d0 100644
--- a/dashboard/app.py
+++ b/dashboard/app.py
@@ -11,7 +11,7 @@ import os
import secrets
from database import get_db, init_db
-from models import User, AgentCatalog, AgentInstance, Run, LLMProvider
+from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge
app = FastAPI(title="Agent Command Center", version="2026.04.12.01")
@@ -479,6 +479,230 @@ def admin_delete_catalog(catalog_id: str, admin: dict = Depends(require_admin),
return {"status": "deleted"}
+# --- Bridge Management ---
+
+class BridgeRegister(BaseModel):
+ user_id: int
+ api_key: str
+ bridge_url: str
+ hostname: str = ""
+ platform: str = "macos"
+ capabilities: list = []
+
+
+class BridgeHeartbeat(BaseModel):
+ bridge_url: str = ""
+ capabilities: list = []
+
+
+@app.post("/api/bridge/register")
+def register_bridge(data: BridgeRegister, db: Session = Depends(get_db)):
+ """Called by bridge installer to register with the dashboard."""
+ user = db.query(User).filter(User.id == data.user_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ # Remove existing bridge for this user (one bridge per user)
+ db.query(Bridge).filter(Bridge.user_id == data.user_id).delete()
+ bridge = Bridge(
+ user_id=data.user_id,
+ api_key=data.api_key,
+ bridge_url=data.bridge_url,
+ hostname=data.hostname,
+ platform=data.platform,
+ capabilities=data.capabilities,
+ status="online",
+ last_heartbeat=datetime.now(timezone.utc),
+ )
+ db.add(bridge)
+ db.commit()
+ return {"status": "registered", "bridge_id": bridge.id}
+
+
+@app.post("/api/bridge/heartbeat")
+def bridge_heartbeat(data: BridgeHeartbeat, request_api_key: str = None,
+ db: Session = Depends(get_db)):
+ """Called periodically by the bridge to update its status and IP."""
+ # Auth via X-Bridge-Token header
+ from fastapi import Request
+ return {"status": "ok"}
+
+
+@app.post("/api/bridge/{api_key}/heartbeat")
+def bridge_heartbeat_keyed(api_key: str, data: BridgeHeartbeat, db: Session = Depends(get_db)):
+ """Heartbeat endpoint keyed by bridge API key."""
+ bridge = db.query(Bridge).filter(Bridge.api_key == api_key).first()
+ if not bridge:
+ raise HTTPException(status_code=404, detail="Bridge not found")
+ if data.bridge_url:
+ bridge.bridge_url = data.bridge_url
+ if data.capabilities:
+ bridge.capabilities = data.capabilities
+ bridge.status = "online"
+ bridge.last_heartbeat = datetime.now(timezone.utc)
+ db.commit()
+ return {"status": "ok"}
+
+
+@app.get("/api/bridge/me")
+def get_my_bridge(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
+ """Get the current user's bridge info."""
+ bridge = db.query(Bridge).filter(Bridge.user_id == user["user_id"]).first()
+ if not bridge:
+ return {"connected": False}
+ # Mark offline if no heartbeat in 10 minutes
+ if bridge.last_heartbeat:
+ age = (datetime.now(timezone.utc) - bridge.last_heartbeat).total_seconds()
+ if age > 600:
+ bridge.status = "offline"
+ db.commit()
+ return {
+ "connected": True,
+ "bridge_url": bridge.bridge_url,
+ "hostname": bridge.hostname,
+ "platform": bridge.platform,
+ "capabilities": bridge.capabilities or [],
+ "status": bridge.status,
+ "last_heartbeat": bridge.last_heartbeat.isoformat() if bridge.last_heartbeat else None,
+ }
+
+
+@app.delete("/api/bridge/me")
+def disconnect_bridge(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
+ """Disconnect the current user's bridge."""
+ db.query(Bridge).filter(Bridge.user_id == user["user_id"]).delete()
+ db.commit()
+ return {"status": "disconnected"}
+
+
+@app.get("/api/users/{user_id}/bridge")
+def get_user_bridge(user_id: int, db: Session = Depends(get_db)):
+ """Internal endpoint for agents to get a user's bridge URL + auth."""
+ bridge = db.query(Bridge).filter(Bridge.user_id == user_id).first()
+ if not bridge or bridge.status == "offline":
+ return {"available": False}
+ return {
+ "available": True,
+ "bridge_url": bridge.bridge_url,
+ "api_key": bridge.api_key,
+ }
+
+
+@app.get("/api/bridge/install-script")
+def get_install_script(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
+ """Generate a personalized bridge installer script for the current user."""
+ user_id = user["user_id"]
+ username = user["username"]
+ api_key = secrets.token_urlsafe(32)
+ dashboard_url = "https://agents.jfamily.io"
+
+ script = f'''#!/bin/bash
+# Agent Command Center — Mac Bridge Installer
+# Generated for: {username} (user {user_id})
+# Run this on your Mac to install the Apple Ecosystem Bridge.
+
+set -e
+echo "Installing Agent Command Center Mac Bridge for {username}..."
+
+BRIDGE_DIR="$HOME/.local/apple-bridge"
+mkdir -p "$BRIDGE_DIR"
+
+# Download bridge.py
+cat > "$BRIDGE_DIR/bridge.py" << 'BRIDGE_EOF'
+BRIDGE_PLACEHOLDER
+BRIDGE_EOF
+
+# Create config
+cat > "$BRIDGE_DIR/config.json" << 'CONFIG_EOF'
+{{
+ "user_id": {user_id},
+ "username": "{username}",
+ "api_key": "{api_key}",
+ "dashboard_url": "{dashboard_url}",
+ "port": {8551 + user_id}
+}}
+CONFIG_EOF
+
+# Create launcher
+cat > "$BRIDGE_DIR/start-bridge.sh" << 'LAUNCHER_EOF'
+#!/bin/bash
+exec /opt/homebrew/bin/python3 "$HOME/.local/apple-bridge/bridge.py"
+LAUNCHER_EOF
+chmod +x "$BRIDGE_DIR/start-bridge.sh"
+
+# Create launchd plist
+PLIST="$HOME/Library/LaunchAgents/com.jfamily.apple-bridge.plist"
+cat > "$PLIST" << PLIST_EOF
+
+
+
+
+ Label
+ com.jfamily.apple-bridge
+ ProgramArguments
+
+ $BRIDGE_DIR/start-bridge.sh
+
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ /tmp/apple-bridge.log
+ StandardErrorPath
+ /tmp/apple-bridge.err
+
+
+PLIST_EOF
+
+# Install Python dependencies
+echo "Installing Python dependencies..."
+/opt/homebrew/bin/pip3 install --break-system-packages -q fastapi uvicorn 2>/dev/null || pip3 install --break-system-packages -q fastapi uvicorn
+
+# Start the bridge
+echo "Starting bridge..."
+launchctl unload "$PLIST" 2>/dev/null
+launchctl load "$PLIST"
+sleep 3
+
+# Register with dashboard
+BRIDGE_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "unknown")
+BRIDGE_PORT={8551 + user_id}
+HOSTNAME=$(hostname)
+
+echo "Registering with dashboard..."
+curl -s -X POST "{dashboard_url}/api/bridge/register" \\
+ -H "Content-Type: application/json" \\
+ -d '{{"user_id": {user_id}, "api_key": "{api_key}", "bridge_url": "http://'$BRIDGE_IP':'$BRIDGE_PORT'", "hostname": "'$HOSTNAME'", "platform": "macos", "capabilities": ["notes", "reading-list"]}}'
+
+echo ""
+echo "===================================="
+echo " Mac Bridge installed for {username}!"
+echo " Bridge URL: http://$BRIDGE_IP:$BRIDGE_PORT"
+echo " Dashboard: {dashboard_url}"
+echo "===================================="
+'''
+ from fastapi.responses import PlainTextResponse
+ return PlainTextResponse(script, media_type="text/x-shellscript",
+ headers={"Content-Disposition": f"attachment; filename=install-bridge-{username}.sh"})
+
+
+# Admin: Bridge overview
+@app.get("/api/admin/bridges")
+def admin_list_bridges(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
+ bridges = db.query(Bridge).all()
+ return [{
+ "id": b.id,
+ "user_id": b.user_id,
+ "username": db.query(User).filter(User.id == b.user_id).first().username if db.query(User).filter(User.id == b.user_id).first() else "?",
+ "bridge_url": b.bridge_url,
+ "hostname": b.hostname,
+ "platform": b.platform,
+ "capabilities": b.capabilities or [],
+ "status": b.status,
+ "last_heartbeat": b.last_heartbeat.isoformat() if b.last_heartbeat else None,
+ } for b in bridges]
+
+
# --- Static files ---
app.mount("/static", StaticFiles(directory="static"), name="static")
diff --git a/dashboard/models.py b/dashboard/models.py
index 621e7a1..142072c 100644
--- a/dashboard/models.py
+++ b/dashboard/models.py
@@ -65,6 +65,23 @@ class Run(Base):
instance = relationship("AgentInstance", back_populates="runs")
+class Bridge(Base):
+ __tablename__ = "bridges"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ api_key = Column(String, nullable=False) # Auth token for bridge requests
+ bridge_url = Column(String, default="") # http://ip:port
+ hostname = Column(String, default="") # e.g. "Jungbauers-MBP"
+ platform = Column(String, default="macos") # macos, ios (future)
+ capabilities = Column(JSON, default=list) # ["notes", "reading-list"]
+ status = Column(String, default="offline") # online, offline
+ last_heartbeat = Column(DateTime, nullable=True)
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
+
+ user = relationship("User")
+
+
class LLMProvider(Base):
__tablename__ = "llm_providers"
diff --git a/dashboard/static/admin.html b/dashboard/static/admin.html
index 909749e..2f2bbe2 100644
--- a/dashboard/static/admin.html
+++ b/dashboard/static/admin.html
@@ -64,6 +64,7 @@ tr:hover td{background:var(--surface2)}
Users
Agent Catalog
LLM Providers
+ Bridges
System
@@ -126,6 +127,11 @@ tr:hover td{background:var(--surface2)}
| Name | Type | URL | Model | Default | Actions |
|---|
+
+
+
| User | Hostname | URL | Platform | Status | Last Heartbeat | Capabilities |
|---|
+
+
@@ -203,6 +209,24 @@ async function createProvider(){
}
async function deleteProvider(id){if(!confirm('Delete this provider?'))return;await fetch(API+'/api/admin/llm-providers/'+id,{method:'DELETE'});loadProviders()}
+// --- Bridges ---
+async function loadBridges(){
+ const res=await fetch(API+'/api/admin/bridges');
+ if(!res.ok)return;
+ const bridges=await res.json();
+ const tbody=document.querySelector('#bridges-table tbody');
+ if(!bridges.length){tbody.innerHTML='
| No bridges connected |
';return}
+ tbody.innerHTML=bridges.map(b=>`
+ | ${b.username} |
+ ${b.hostname||'-'} |
+ ${b.bridge_url||'-'} |
+ ${b.platform} |
+ ${b.status} |
+ ${b.last_heartbeat||'-'} |
+ ${(b.capabilities||[]).join(', ')} |
+
`).join('');
+}
+
// --- System ---
async function loadSystem(){
const[usersRes,instRes]=await Promise.all([fetch(API+'/api/admin/users'),fetch(API+'/api/health')]);
@@ -215,7 +239,7 @@ async function loadSystem(){
}
// Init
-loadUsers();loadCatalog();loadProviders();loadSystem();
+loadUsers();loadCatalog();loadProviders();loadBridges();loadSystem();