From 4dea7715459bac49e27f7abba850dfb39dfc5330 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 4 Feb 2026 16:04:52 +0530 Subject: [PATCH] feat(boards): Store gateway config per board Move gateway configuration into board settings and wire agent\nprovisioning, heartbeat templates, and gateway status lookups\nto use board-specific gateway settings. Adds board_id on agents\nand UI updates for board-scoped selection.\n\nCo-Authored-By: Claude --- backend/.env.example | 6 - .../f1a2b3c4d5e6_add_board_gateway_config.py | 47 ++++ backend/app/api/agents.py | 76 ++++-- backend/app/api/boards.py | 7 +- backend/app/api/gateway.py | 114 +++++--- backend/app/core/config.py | 6 - backend/app/integrations/openclaw_gateway.py | 79 ++++-- backend/app/models/agents.py | 1 + backend/app/models/boards.py | 4 + backend/app/schemas/agents.py | 3 + backend/app/schemas/boards.py | 9 +- backend/app/services/agent_provisioning.py | 35 ++- .../src/app/agents/[agentId]/edit/page.tsx | 70 ++++- frontend/src/app/agents/[agentId]/page.tsx | 27 +- frontend/src/app/agents/new/page.tsx | 72 ++++- frontend/src/app/agents/page.tsx | 252 ++++++++++++------ frontend/src/app/boards/[boardId]/page.tsx | 142 +++++++++- frontend/src/app/boards/new/page.tsx | 69 ++++- templates/HEARTBEAT.md | 3 +- templates/TOOLS.md | 1 + 20 files changed, 827 insertions(+), 196 deletions(-) create mode 100644 backend/alembic/versions/f1a2b3c4d5e6_add_board_gateway_config.py diff --git a/backend/.env.example b/backend/.env.example index b79207c5..e037de20 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,11 +10,5 @@ CLERK_JWKS_URL= CLERK_VERIFY_IAT=true CLERK_LEEWAY=10.0 -# OpenClaw Gateway -OPENCLAW_GATEWAY_URL=ws://127.0.0.1:18789 -OPENCLAW_GATEWAY_TOKEN= -OPENCLAW_MAIN_SESSION_KEY=agent:main:main -OPENCLAW_WORKSPACE_ROOT=~/.openclaw/workspaces - # Database DB_AUTO_MIGRATE=false diff --git a/backend/alembic/versions/f1a2b3c4d5e6_add_board_gateway_config.py b/backend/alembic/versions/f1a2b3c4d5e6_add_board_gateway_config.py new file mode 100644 index 00000000..ea5623a5 --- /dev/null +++ b/backend/alembic/versions/f1a2b3c4d5e6_add_board_gateway_config.py @@ -0,0 +1,47 @@ +"""add board gateway config + +Revision ID: f1a2b3c4d5e6 +Revises: e4f5a6b7c8d9 +Create Date: 2026-02-04 00:00:00.000000 + +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "f1a2b3c4d5e6" +down_revision = "e4f5a6b7c8d9" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("boards", sa.Column("gateway_url", sa.String(), nullable=True)) + op.add_column("boards", sa.Column("gateway_token", sa.String(), nullable=True)) + op.add_column( + "boards", sa.Column("gateway_main_session_key", sa.String(), nullable=True) + ) + op.add_column( + "boards", sa.Column("gateway_workspace_root", sa.String(), nullable=True) + ) + + op.add_column("agents", sa.Column("board_id", sa.Uuid(), nullable=True)) + op.create_foreign_key( + "agents_board_id_fkey", "agents", "boards", ["board_id"], ["id"] + ) + op.create_index(op.f("ix_agents_board_id"), "agents", ["board_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_agents_board_id"), table_name="agents") + op.drop_constraint("agents_board_id_fkey", "agents", type_="foreignkey") + op.drop_column("agents", "board_id") + + op.drop_column("boards", "gateway_workspace_root") + op.drop_column("boards", "gateway_main_session_key") + op.drop_column("boards", "gateway_token") + op.drop_column("boards", "gateway_url") diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 978f9a0f..8a611383 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -2,7 +2,7 @@ from __future__ import annotations import re from datetime import datetime, timedelta -from uuid import uuid4 +from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel import Session, col, select @@ -11,9 +11,9 @@ from sqlalchemy import update from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent from app.core.agent_tokens import generate_agent_token, hash_agent_token from app.core.auth import AuthContext -from app.core.config import settings from app.db.session import get_session from app.integrations.openclaw_gateway import ( + GatewayConfig, OpenClawGatewayError, delete_session, ensure_session, @@ -21,6 +21,7 @@ from app.integrations.openclaw_gateway import ( ) from app.models.agents import Agent from app.models.activity_events import ActivityEvent +from app.models.boards import Board from app.schemas.agents import ( AgentCreate, AgentHeartbeat, @@ -46,10 +47,34 @@ def _build_session_key(agent_name: str) -> str: return f"{AGENT_SESSION_PREFIX}:{_slugify(agent_name)}:main" -async def _ensure_gateway_session(agent_name: str) -> tuple[str, str | None]: +def _require_board(session: Session, board_id: UUID | str | None) -> Board: + if not board_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="board_id is required", + ) + board = session.get(Board, board_id) + if board is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found") + return board + + +def _require_gateway_config(board: Board) -> GatewayConfig: + if not board.gateway_url: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board gateway_url is required", + ) + return GatewayConfig(url=board.gateway_url, token=board.gateway_token) + + +async def _ensure_gateway_session( + agent_name: str, + config: GatewayConfig, +) -> tuple[str, str | None]: session_key = _build_session_key(agent_name) try: - await ensure_session(session_key, label=agent_name) + await ensure_session(session_key, config=config, label=agent_name) return session_key, None except OpenClawGatewayError as exc: return session_key, str(exc) @@ -97,11 +122,13 @@ async def create_agent( session: Session = Depends(get_session), auth: AuthContext = Depends(require_admin_auth), ) -> Agent: + board = _require_board(session, payload.board_id) + config = _require_gateway_config(board) agent = Agent.model_validate(payload) agent.status = "provisioning" raw_token = generate_agent_token() agent.agent_token_hash = hash_agent_token(raw_token) - session_key, session_error = await _ensure_gateway_session(agent.name) + session_key, session_error = await _ensure_gateway_session(agent.name, config) agent.openclaw_session_id = session_key session.add(agent) session.commit() @@ -122,7 +149,7 @@ async def create_agent( ) session.commit() try: - await send_provisioning_message(agent, raw_token) + await send_provisioning_message(agent, board, raw_token) except OpenClawGatewayError as exc: _record_provisioning_failure(session, agent, str(exc)) session.commit() @@ -160,6 +187,8 @@ def update_agent( status_code=status.HTTP_403_FORBIDDEN, detail="status is controlled by agent heartbeat", ) + if "board_id" in updates and updates["board_id"]: + _require_board(session, str(updates["board_id"])) for key, value in updates.items(): setattr(agent, key, value) agent.updated_at = datetime.utcnow() @@ -204,10 +233,12 @@ async def heartbeat_or_create_agent( if agent is None: if actor.actor_type == "agent": raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - agent = Agent(name=payload.name, status="provisioning") + board = _require_board(session, payload.board_id) + config = _require_gateway_config(board) + agent = Agent(name=payload.name, status="provisioning", board_id=board.id) raw_token = generate_agent_token() agent.agent_token_hash = hash_agent_token(raw_token) - session_key, session_error = await _ensure_gateway_session(agent.name) + session_key, session_error = await _ensure_gateway_session(agent.name, config) agent.openclaw_session_id = session_key session.add(agent) session.commit() @@ -228,7 +259,7 @@ async def heartbeat_or_create_agent( ) session.commit() try: - await send_provisioning_message(agent, raw_token) + await send_provisioning_message(agent, board, raw_token) except OpenClawGatewayError as exc: _record_provisioning_failure(session, agent, str(exc)) session.commit() @@ -244,7 +275,9 @@ async def heartbeat_or_create_agent( session.commit() session.refresh(agent) try: - await send_provisioning_message(agent, raw_token) + board = _require_board(session, str(agent.board_id) if agent.board_id else None) + _require_gateway_config(board) + await send_provisioning_message(agent, board, raw_token) except OpenClawGatewayError as exc: _record_provisioning_failure(session, agent, str(exc)) session.commit() @@ -252,7 +285,9 @@ async def heartbeat_or_create_agent( _record_provisioning_failure(session, agent, str(exc)) session.commit() elif not agent.openclaw_session_id: - session_key, session_error = await _ensure_gateway_session(agent.name) + board = _require_board(session, str(agent.board_id) if agent.board_id else None) + config = _require_gateway_config(board) + session_key, session_error = await _ensure_gateway_session(agent.name, config) agent.openclaw_session_id = session_key if session_error: record_activity( @@ -290,12 +325,16 @@ def delete_agent( ) -> dict[str, bool]: agent = session.get(Agent, agent_id) if agent: + board = _require_board(session, str(agent.board_id) if agent.board_id else None) + config = _require_gateway_config(board) async def _gateway_cleanup() -> None: if agent.openclaw_session_id: - await delete_session(agent.openclaw_session_id) - main_session = settings.openclaw_main_session_key + await delete_session(agent.openclaw_session_id, config=config) + main_session = board.gateway_main_session_key or "agent:main:main" if main_session: - workspace_root = settings.openclaw_workspace_root or "~/.openclaw/workspaces" + workspace_root = ( + board.gateway_workspace_root or "~/.openclaw/workspaces" + ) workspace_path = f"{workspace_root.rstrip('/')}/{_slugify(agent.name)}" cleanup_message = ( "Cleanup request for deleted agent.\n\n" @@ -308,8 +347,13 @@ def delete_agent( "2) Delete any lingering session artifacts.\n" "Reply NO_REPLY." ) - await ensure_session(main_session, label="Main Agent") - await send_message(cleanup_message, session_key=main_session, deliver=False) + await ensure_session(main_session, config=config, label="Main Agent") + await send_message( + cleanup_message, + session_key=main_session, + config=config, + deliver=False, + ) try: import asyncio diff --git a/backend/app/api/boards.py b/backend/app/api/boards.py index 3f4a4e60..a10efe70 100644 --- a/backend/app/api/boards.py +++ b/backend/app/api/boards.py @@ -31,7 +31,10 @@ def create_board( session: Session = Depends(get_session), auth: AuthContext = Depends(require_admin_auth), ) -> Board: - board = Board.model_validate(payload) + data = payload.model_dump() + if data.get("gateway_token") == "": + data["gateway_token"] = None + board = Board.model_validate(data) session.add(board) session.commit() session.refresh(board) @@ -54,6 +57,8 @@ def update_board( auth: AuthContext = Depends(require_admin_auth), ) -> Board: updates = payload.model_dump(exclude_unset=True) + if updates.get("gateway_token") == "": + updates["gateway_token"] = None for key, value in updates.items(): setattr(board, key, value) session.add(board) diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index 8d5d09a6..e5f625ad 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -1,43 +1,69 @@ from __future__ import annotations -from fastapi import APIRouter, Body, Depends, HTTPException, status +from fastapi import APIRouter, Body, Depends, HTTPException, Query, status +from sqlmodel import Session from app.api.deps import require_admin_auth from app.core.auth import AuthContext -from app.core.config import settings from app.integrations.openclaw_gateway import ( + GatewayConfig, OpenClawGatewayError, ensure_session, get_chat_history, openclaw_call, send_message, ) +from app.db.session import get_session +from app.models.boards import Board router = APIRouter(prefix="/gateway", tags=["gateway"]) +def _require_board_config(session: Session, board_id: str | None) -> tuple[Board, GatewayConfig]: + if not board_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="board_id is required", + ) + board = session.get(Board, board_id) + if board is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found") + if not board.gateway_url: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board gateway_url is required", + ) + return board, GatewayConfig(url=board.gateway_url, token=board.gateway_token) + + @router.get("/status") -async def gateway_status(auth: AuthContext = Depends(require_admin_auth)) -> dict[str, object]: - gateway_url = settings.openclaw_gateway_url or "ws://127.0.0.1:18789" +async def gateway_status( + board_id: str | None = Query(default=None), + session: Session = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), +) -> dict[str, object]: + board, config = _require_board_config(session, board_id) try: - sessions = await openclaw_call("sessions.list") + sessions = await openclaw_call("sessions.list", config=config) if isinstance(sessions, dict): sessions_list = list(sessions.get("sessions") or []) else: sessions_list = list(sessions or []) - main_session = settings.openclaw_main_session_key + main_session = board.gateway_main_session_key or "agent:main:main" main_session_entry: object | None = None main_session_error: str | None = None if main_session: try: - ensured = await ensure_session(main_session, label="Main Agent") + ensured = await ensure_session( + main_session, config=config, label="Main Agent" + ) if isinstance(ensured, dict): main_session_entry = ensured.get("entry") or ensured except OpenClawGatewayError as exc: main_session_error = str(exc) return { "connected": True, - "gateway_url": gateway_url, + "gateway_url": board.gateway_url, "sessions_count": len(sessions_list), "sessions": sessions_list, "main_session_key": main_session, @@ -47,15 +73,20 @@ async def gateway_status(auth: AuthContext = Depends(require_admin_auth)) -> dic except OpenClawGatewayError as exc: return { "connected": False, - "gateway_url": gateway_url, + "gateway_url": board.gateway_url, "error": str(exc), } @router.get("/sessions") -async def list_sessions(auth: AuthContext = Depends(require_admin_auth)) -> dict[str, object]: +async def list_sessions( + board_id: str | None = Query(default=None), + session: Session = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), +) -> dict[str, object]: + board, config = _require_board_config(session, board_id) try: - sessions = await openclaw_call("sessions.list") + sessions = await openclaw_call("sessions.list", config=config) except OpenClawGatewayError as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc if isinstance(sessions, dict): @@ -63,63 +94,79 @@ async def list_sessions(auth: AuthContext = Depends(require_admin_auth)) -> dict else: sessions_list = list(sessions or []) - main_session = settings.openclaw_main_session_key + main_session = board.gateway_main_session_key or "agent:main:main" main_session_entry: object | None = None if main_session: try: - ensured = await ensure_session(main_session, label="Main Agent") + ensured = await ensure_session( + main_session, config=config, label="Main Agent" + ) if isinstance(ensured, dict): main_session_entry = ensured.get("entry") or ensured except OpenClawGatewayError: main_session_entry = None - return {"sessions": sessions_list, "main_session_key": main_session, "main_session": main_session_entry} + return { + "sessions": sessions_list, + "main_session_key": main_session, + "main_session": main_session_entry, + } @router.get("/sessions/{session_id}") -async def get_session( - session_id: str, auth: AuthContext = Depends(require_admin_auth) +async def get_gateway_session( + session_id: str, + board_id: str | None = Query(default=None), + session: Session = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), ) -> dict[str, object]: + board, config = _require_board_config(session, board_id) try: - sessions = await openclaw_call("sessions.list") + sessions = await openclaw_call("sessions.list", config=config) except OpenClawGatewayError as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc if isinstance(sessions, dict): sessions_list = list(sessions.get("sessions") or []) else: sessions_list = list(sessions or []) - main_session = settings.openclaw_main_session_key + main_session = board.gateway_main_session_key or "agent:main:main" if main_session and not any( session.get("key") == main_session for session in sessions_list ): try: - await ensure_session(main_session, label="Main Agent") - refreshed = await openclaw_call("sessions.list") + await ensure_session(main_session, config=config, label="Main Agent") + refreshed = await openclaw_call("sessions.list", config=config) if isinstance(refreshed, dict): sessions_list = list(refreshed.get("sessions") or []) else: sessions_list = list(refreshed or []) except OpenClawGatewayError: pass - session = next((item for item in sessions_list if item.get("key") == session_id), None) - if session is None and main_session and session_id == main_session: + session_entry = next( + (item for item in sessions_list if item.get("key") == session_id), None + ) + if session_entry is None and main_session and session_id == main_session: try: - ensured = await ensure_session(main_session, label="Main Agent") + ensured = await ensure_session(main_session, config=config, label="Main Agent") if isinstance(ensured, dict): - session = ensured.get("entry") or ensured + session_entry = ensured.get("entry") or ensured except OpenClawGatewayError: - session = None - if session is None: + session_entry = None + if session_entry is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") - return {"session": session} + return {"session": session_entry} @router.get("/sessions/{session_id}/history") async def get_session_history( - session_id: str, auth: AuthContext = Depends(require_admin_auth) + session_id: str, + board_id: str | None = Query(default=None), + session: Session = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), ) -> dict[str, object]: + _, config = _require_board_config(session, board_id) try: - history = await get_chat_history(session_id) + history = await get_chat_history(session_id, config=config) except OpenClawGatewayError as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc if isinstance(history, dict) and isinstance(history.get("messages"), list): @@ -131,6 +178,8 @@ async def get_session_history( async def send_session_message( session_id: str, payload: dict = Body(...), + board_id: str | None = Query(default=None), + session: Session = Depends(get_session), auth: AuthContext = Depends(require_admin_auth), ) -> dict[str, bool]: content = payload.get("content") @@ -138,11 +187,12 @@ async def send_session_message( raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="content is required" ) + board, config = _require_board_config(session, board_id) try: - main_session = settings.openclaw_main_session_key + main_session = board.gateway_main_session_key or "agent:main:main" if main_session and session_id == main_session: - await ensure_session(main_session, label="Main Agent") - await send_message(content, session_key=session_id) + await ensure_session(main_session, config=config, label="Main Agent") + await send_message(content, session_key=session_id, config=config) except OpenClawGatewayError as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc return {"ok": True} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index af22ac40..11e0fdb0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -19,12 +19,6 @@ class Settings(BaseSettings): clerk_verify_iat: bool = True clerk_leeway: float = 10.0 - # OpenClaw Gateway - openclaw_gateway_url: str = "" - openclaw_gateway_token: str = "" - openclaw_main_session_key: str = "agent:main:main" - openclaw_workspace_root: str = "~/.openclaw/workspaces" - cors_origins: str = "" base_url: str = "" diff --git a/backend/app/integrations/openclaw_gateway.py b/backend/app/integrations/openclaw_gateway.py index 62bafab0..74b98847 100644 --- a/backend/app/integrations/openclaw_gateway.py +++ b/backend/app/integrations/openclaw_gateway.py @@ -9,7 +9,6 @@ from uuid import uuid4 import websockets -from app.core.config import settings class OpenClawGatewayError(RuntimeError): @@ -21,9 +20,17 @@ class OpenClawResponse: payload: Any -def _build_gateway_url() -> str: - base_url = settings.openclaw_gateway_url or "ws://127.0.0.1:18789" - token = settings.openclaw_gateway_token +@dataclass(frozen=True) +class GatewayConfig: + url: str + token: str | None = None + + +def _build_gateway_url(config: GatewayConfig) -> str: + base_url = (config.url or "").strip() + if not base_url: + raise OpenClawGatewayError("Gateway URL is not configured for this board.") + token = config.token if not token: return base_url parsed = urlparse(base_url) @@ -58,7 +65,9 @@ async def _send_request( async def _handle_challenge( - ws: websockets.WebSocketClientProtocol, first_message: str | bytes | None + ws: websockets.WebSocketClientProtocol, + first_message: str | bytes | None, + config: GatewayConfig, ) -> None: if not first_message: return @@ -69,28 +78,35 @@ async def _handle_challenge( return connect_id = str(uuid4()) + params: dict[str, Any] = { + "minProtocol": 3, + "maxProtocol": 3, + "client": { + "id": "gateway-client", + "version": "1.0.0", + "platform": "web", + "mode": "ui", + }, + } + if config.token: + params["auth"] = {"token": config.token} response = { "type": "req", "id": connect_id, "method": "connect", - "params": { - "minProtocol": 3, - "maxProtocol": 3, - "client": { - "id": "gateway-client", - "version": "1.0.0", - "platform": "web", - "mode": "ui", - }, - "auth": {"token": settings.openclaw_gateway_token}, - }, + "params": params, } await ws.send(json.dumps(response)) await _await_response(ws, connect_id) -async def openclaw_call(method: str, params: dict[str, Any] | None = None) -> Any: - gateway_url = _build_gateway_url() +async def openclaw_call( + method: str, + params: dict[str, Any] | None = None, + *, + config: GatewayConfig, +) -> Any: + gateway_url = _build_gateway_url(config) try: async with websockets.connect(gateway_url, ping_interval=None) as ws: first_message = None @@ -98,7 +114,7 @@ async def openclaw_call(method: str, params: dict[str, Any] | None = None) -> An first_message = await asyncio.wait_for(ws.recv(), timeout=2) except asyncio.TimeoutError: first_message = None - await _handle_challenge(ws, first_message) + await _handle_challenge(ws, first_message, config) return await _send_request(ws, method, params) except OpenClawGatewayError: raise @@ -110,6 +126,7 @@ async def send_message( message: str, *, session_key: str, + config: GatewayConfig, deliver: bool = False, ) -> Any: params: dict[str, Any] = { @@ -118,23 +135,31 @@ async def send_message( "deliver": deliver, "idempotencyKey": str(uuid4()), } - return await openclaw_call("chat.send", params) + return await openclaw_call("chat.send", params, config=config) -async def get_chat_history(session_key: str, limit: int | None = None) -> Any: +async def get_chat_history( + session_key: str, + config: GatewayConfig, + limit: int | None = None, +) -> Any: params: dict[str, Any] = {"sessionKey": session_key} if limit is not None: params["limit"] = limit - return await openclaw_call("chat.history", params) + return await openclaw_call("chat.history", params, config=config) -async def delete_session(session_key: str) -> Any: - return await openclaw_call("sessions.delete", {"key": session_key}) +async def delete_session(session_key: str, *, config: GatewayConfig) -> Any: + return await openclaw_call("sessions.delete", {"key": session_key}, config=config) -async def ensure_session(session_key: str, label: str | None = None) -> Any: +async def ensure_session( + session_key: str, + *, + config: GatewayConfig, + label: str | None = None, +) -> Any: params: dict[str, Any] = {"key": session_key} if label: params["label"] = label - return await openclaw_call("sessions.patch", params) - + return await openclaw_call("sessions.patch", params, config=config) diff --git a/backend/app/models/agents.py b/backend/app/models/agents.py index 29079845..7fdbeff2 100644 --- a/backend/app/models/agents.py +++ b/backend/app/models/agents.py @@ -10,6 +10,7 @@ class Agent(SQLModel, table=True): __tablename__ = "agents" id: UUID = Field(default_factory=uuid4, primary_key=True) + board_id: UUID | None = Field(default=None, foreign_key="boards.id", index=True) name: str = Field(index=True) status: str = Field(default="provisioning", index=True) openclaw_session_id: str | None = Field(default=None, index=True) diff --git a/backend/app/models/boards.py b/backend/app/models/boards.py index 65cc340e..82ce1f7a 100644 --- a/backend/app/models/boards.py +++ b/backend/app/models/boards.py @@ -14,5 +14,9 @@ class Board(TenantScoped, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) name: str slug: str = Field(index=True) + gateway_url: str | None = Field(default=None) + gateway_token: str | None = Field(default=None) + gateway_main_session_key: str | None = Field(default=None) + gateway_workspace_root: str | None = Field(default=None) created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/app/schemas/agents.py b/backend/app/schemas/agents.py index ec6697ba..ff4a30c2 100644 --- a/backend/app/schemas/agents.py +++ b/backend/app/schemas/agents.py @@ -7,6 +7,7 @@ from sqlmodel import SQLModel class AgentBase(SQLModel): + board_id: UUID | None = None name: str status: str = "provisioning" @@ -16,6 +17,7 @@ class AgentCreate(AgentBase): class AgentUpdate(SQLModel): + board_id: UUID | None = None name: str | None = None status: str | None = None @@ -34,3 +36,4 @@ class AgentHeartbeat(SQLModel): class AgentHeartbeatCreate(AgentHeartbeat): name: str + board_id: UUID | None = None diff --git a/backend/app/schemas/boards.py b/backend/app/schemas/boards.py index 7ca3fae8..d40c1db7 100644 --- a/backend/app/schemas/boards.py +++ b/backend/app/schemas/boards.py @@ -9,15 +9,22 @@ from sqlmodel import SQLModel class BoardBase(SQLModel): name: str slug: str + gateway_url: str | None = None + gateway_main_session_key: str | None = None + gateway_workspace_root: str | None = None class BoardCreate(BoardBase): - pass + gateway_token: str | None = None class BoardUpdate(SQLModel): name: str | None = None slug: str | None = None + gateway_url: str | None = None + gateway_token: str | None = None + gateway_main_session_key: str | None = None + gateway_workspace_root: str | None = None class BoardRead(BoardBase): diff --git a/backend/app/services/agent_provisioning.py b/backend/app/services/agent_provisioning.py index 02110dc6..27ffbe97 100644 --- a/backend/app/services/agent_provisioning.py +++ b/backend/app/services/agent_provisioning.py @@ -7,8 +7,9 @@ from uuid import uuid4 from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape from app.core.config import settings -from app.integrations.openclaw_gateway import ensure_session, send_message +from app.integrations.openclaw_gateway import GatewayConfig, ensure_session, send_message from app.models.agents import Agent +from app.models.boards import Board TEMPLATE_FILES = [ "AGENTS.md", @@ -62,27 +63,30 @@ def _render_file_block(name: str, content: str) -> str: return f"\n{name}\n```md\n{body}\n```\n" -def _workspace_path(agent_name: str) -> str: - root = settings.openclaw_workspace_root or "~/.openclaw/workspaces" +def _workspace_path(agent_name: str, workspace_root: str) -> str: + root = workspace_root or "~/.openclaw/workspaces" root = root.rstrip("/") return f"{root}/{_slugify(agent_name)}" -def build_provisioning_message(agent: Agent, auth_token: str) -> str: +def build_provisioning_message(agent: Agent, board: Board, auth_token: str) -> str: agent_id = str(agent.id) - workspace_path = _workspace_path(agent.name) + workspace_root = board.gateway_workspace_root or "~/.openclaw/workspaces" + workspace_path = _workspace_path(agent.name, workspace_root) session_key = agent.openclaw_session_id or "" base_url = settings.base_url or "REPLACE_WITH_BASE_URL" + main_session_key = board.gateway_main_session_key or "agent:main:main" context = { "agent_name": agent.name, "agent_id": agent_id, + "board_id": str(board.id), "session_key": session_key, "workspace_path": workspace_path, "base_url": base_url, "auth_token": auth_token, - "main_session_key": settings.openclaw_main_session_key or "agent:main:main", - "workspace_root": settings.openclaw_workspace_root or "~/.openclaw/workspaces", + "main_session_key": main_session_key, + "workspace_root": workspace_root, "user_name": "Unset", "user_preferred_name": "Unset", "user_timezone": "Unset", @@ -113,10 +117,15 @@ def build_provisioning_message(agent: Agent, auth_token: str) -> str: ) -async def send_provisioning_message(agent: Agent, auth_token: str) -> None: - main_session = settings.openclaw_main_session_key - if not main_session: +async def send_provisioning_message( + agent: Agent, + board: Board, + auth_token: str, +) -> None: + main_session = board.gateway_main_session_key or "agent:main:main" + if not board.gateway_url: return - await ensure_session(main_session, label="Main Agent") - message = build_provisioning_message(agent, auth_token) - await send_message(message, session_key=main_session, deliver=False) + config = GatewayConfig(url=board.gateway_url, token=board.gateway_token) + await ensure_session(main_session, config=config, label="Main Agent") + message = build_provisioning_message(agent, board, auth_token) + await send_message(message, session_key=main_session, config=config, deliver=False) diff --git a/frontend/src/app/agents/[agentId]/edit/page.tsx b/frontend/src/app/agents/[agentId]/edit/page.tsx index 29b4ffef..173debc4 100644 --- a/frontend/src/app/agents/[agentId]/edit/page.tsx +++ b/frontend/src/app/agents/[agentId]/edit/page.tsx @@ -9,6 +9,13 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; const apiBase = process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") || @@ -17,6 +24,13 @@ const apiBase = type Agent = { id: string; name: string; + board_id?: string | null; +}; + +type Board = { + id: string; + name: string; + slug: string; }; export default function EditAgentPage() { @@ -28,9 +42,31 @@ export default function EditAgentPage() { const [agent, setAgent] = useState(null); const [name, setName] = useState(""); + const [boards, setBoards] = useState([]); + const [boardId, setBoardId] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const loadBoards = async () => { + if (!isSignedIn) return; + try { + const token = await getToken(); + const response = await fetch(`${apiBase}/api/v1/boards`, { + headers: { Authorization: token ? `Bearer ${token}` : "" }, + }); + if (!response.ok) { + throw new Error("Unable to load boards."); + } + const data = (await response.json()) as Board[]; + setBoards(data); + if (!boardId && data.length > 0) { + setBoardId(data[0].id); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } + }; + const loadAgent = async () => { if (!isSignedIn || !agentId) return; setIsLoading(true); @@ -46,6 +82,9 @@ export default function EditAgentPage() { const data = (await response.json()) as Agent; setAgent(data); setName(data.name); + if (data.board_id) { + setBoardId(data.board_id); + } } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong."); } finally { @@ -54,6 +93,7 @@ export default function EditAgentPage() { }; useEffect(() => { + loadBoards(); loadAgent(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSignedIn, agentId]); @@ -66,6 +106,10 @@ export default function EditAgentPage() { setError("Agent name is required."); return; } + if (!boardId) { + setError("Select a board before saving."); + return; + } setIsLoading(true); setError(null); try { @@ -76,7 +120,7 @@ export default function EditAgentPage() { "Content-Type": "application/json", Authorization: token ? `Bearer ${token}` : "", }, - body: JSON.stringify({ name: trimmed }), + body: JSON.stringify({ name: trimmed, board_id: boardId }), }); if (!response.ok) { throw new Error("Unable to update agent."); @@ -127,6 +171,30 @@ export default function EditAgentPage() { disabled={isLoading} /> +
+ + + {boards.length === 0 ? ( +

+ Create a board before assigning agents. +

+ ) : null} +
{error ? (
{error} diff --git a/frontend/src/app/agents/[agentId]/page.tsx b/frontend/src/app/agents/[agentId]/page.tsx index 704b7f96..409532c3 100644 --- a/frontend/src/app/agents/[agentId]/page.tsx +++ b/frontend/src/app/agents/[agentId]/page.tsx @@ -31,6 +31,13 @@ type Agent = { last_seen_at: string; created_at: string; updated_at: string; + board_id?: string | null; +}; + +type Board = { + id: string; + name: string; + slug: string; }; type ActivityEvent = { @@ -74,6 +81,7 @@ export default function AgentDetailPage() { const [agent, setAgent] = useState(null); const [events, setEvents] = useState([]); + const [boards, setBoards] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -92,13 +100,16 @@ export default function AgentDetailPage() { setError(null); try { const token = await getToken(); - const [agentResponse, activityResponse] = await Promise.all([ + const [agentResponse, activityResponse, boardsResponse] = await Promise.all([ fetch(`${apiBase}/api/v1/agents/${agentId}`, { headers: { Authorization: token ? `Bearer ${token}` : "" }, }), fetch(`${apiBase}/api/v1/activity?limit=200`, { headers: { Authorization: token ? `Bearer ${token}` : "" }, }), + fetch(`${apiBase}/api/v1/boards`, { + headers: { Authorization: token ? `Bearer ${token}` : "" }, + }), ]); if (!agentResponse.ok) { throw new Error("Unable to load agent."); @@ -106,10 +117,15 @@ export default function AgentDetailPage() { if (!activityResponse.ok) { throw new Error("Unable to load activity."); } + if (!boardsResponse.ok) { + throw new Error("Unable to load boards."); + } const agentData = (await agentResponse.json()) as Agent; const eventsData = (await activityResponse.json()) as ActivityEvent[]; + const boardsData = (await boardsResponse.json()) as Board[]; setAgent(agentData); setEvents(eventsData); + setBoards(boardsData); } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong."); } finally { @@ -233,6 +249,15 @@ export default function AgentDetailPage() { {agent.openclaw_session_id ?? "—"}

+
+

+ Board +

+

+ {boards.find((board) => board.id === agent.board_id)?.name ?? + "—"} +

+

Last seen diff --git a/frontend/src/app/agents/new/page.tsx b/frontend/src/app/agents/new/page.tsx index d9dcb6c1..644975e9 100644 --- a/frontend/src/app/agents/new/page.tsx +++ b/frontend/src/app/agents/new/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; @@ -9,6 +9,13 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; const apiBase = process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") || @@ -19,14 +26,47 @@ type Agent = { name: string; }; +type Board = { + id: string; + name: string; + slug: string; +}; + export default function NewAgentPage() { const router = useRouter(); const { getToken, isSignedIn } = useAuth(); const [name, setName] = useState(""); + const [boards, setBoards] = useState([]); + const [boardId, setBoardId] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const loadBoards = async () => { + if (!isSignedIn) return; + try { + const token = await getToken(); + const response = await fetch(`${apiBase}/api/v1/boards`, { + headers: { Authorization: token ? `Bearer ${token}` : "" }, + }); + if (!response.ok) { + throw new Error("Unable to load boards."); + } + const data = (await response.json()) as Board[]; + setBoards(data); + if (!boardId && data.length > 0) { + setBoardId(data[0].id); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } + }; + + useEffect(() => { + loadBoards(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSignedIn]); + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!isSignedIn) return; @@ -35,6 +75,10 @@ export default function NewAgentPage() { setError("Agent name is required."); return; } + if (!boardId) { + setError("Select a board before creating an agent."); + return; + } setIsLoading(true); setError(null); try { @@ -45,7 +89,7 @@ export default function NewAgentPage() { "Content-Type": "application/json", Authorization: token ? `Bearer ${token}` : "", }, - body: JSON.stringify({ name: trimmed }), + body: JSON.stringify({ name: trimmed, board_id: boardId }), }); if (!response.ok) { throw new Error("Unable to create agent."); @@ -97,6 +141,30 @@ export default function NewAgentPage() { disabled={isLoading} />

+
+ + + {boards.length === 0 ? ( +

+ Create a board before adding agents. +

+ ) : null} +
{error ? (
{error} diff --git a/frontend/src/app/agents/page.tsx b/frontend/src/app/agents/page.tsx index fd151079..4717ad3c 100644 --- a/frontend/src/app/agents/page.tsx +++ b/frontend/src/app/agents/page.tsx @@ -26,6 +26,13 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; const apiBase = process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") || @@ -39,6 +46,13 @@ type Agent = { last_seen_at: string; created_at: string; updated_at: string; + board_id?: string | null; +}; + +type Board = { + id: string; + name: string; + slug: string; }; type GatewayStatus = { @@ -84,6 +98,8 @@ export default function AgentsPage() { const router = useRouter(); const [agents, setAgents] = useState([]); + const [boards, setBoards] = useState([]); + const [boardId, setBoardId] = useState(""); const [sorting, setSorting] = useState([ { id: "name", desc: false }, ]); @@ -98,6 +114,26 @@ export default function AgentsPage() { const sortedAgents = useMemo(() => [...agents], [agents]); + const loadBoards = async () => { + if (!isSignedIn) return; + try { + const token = await getToken(); + const response = await fetch(`${apiBase}/api/v1/boards`, { + headers: { Authorization: token ? `Bearer ${token}` : "" }, + }); + if (!response.ok) { + throw new Error("Unable to load boards."); + } + const data = (await response.json()) as Board[]; + setBoards(data); + if (!boardId && data.length > 0) { + setBoardId(data[0].id); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } + }; + const loadAgents = async () => { if (!isSignedIn) return; setIsLoading(true); @@ -122,13 +158,14 @@ export default function AgentsPage() { }; const loadGatewayStatus = async () => { - if (!isSignedIn) return; + if (!isSignedIn || !boardId) return; setGatewayError(null); try { const token = await getToken(); - const response = await fetch(`${apiBase}/api/v1/gateway/status`, { - headers: { Authorization: token ? `Bearer ${token}` : "" }, - }); + const response = await fetch( + `${apiBase}/api/v1/gateway/status?board_id=${boardId}`, + { headers: { Authorization: token ? `Bearer ${token}` : "" } } + ); if (!response.ok) { throw new Error("Unable to load gateway status."); } @@ -140,11 +177,18 @@ export default function AgentsPage() { }; useEffect(() => { + loadBoards(); loadAgents(); - loadGatewayStatus(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSignedIn]); + useEffect(() => { + if (boardId) { + loadGatewayStatus(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [boardId, isSignedIn]); + const handleDelete = async () => { if (!deleteTarget || !isSignedIn) return; setIsDeleting(true); @@ -169,85 +213,107 @@ export default function AgentsPage() { } }; + const handleRefresh = async () => { + await loadBoards(); + await loadAgents(); + await loadGatewayStatus(); + }; + const columns = useMemo[]>( - () => [ - { - accessorKey: "name", - header: "Agent", - cell: ({ row }) => ( -
-

{row.original.name}

-

ID {row.original.id}

-
- ), - }, - { - accessorKey: "status", - header: "Status", - cell: ({ row }) => , - }, - { - accessorKey: "openclaw_session_id", - header: "Session", - cell: ({ row }) => ( - - {truncate(row.original.openclaw_session_id)} - - ), - }, - { - accessorKey: "last_seen_at", - header: "Last seen", - cell: ({ row }) => ( -
-

- {formatRelative(row.original.last_seen_at)} -

-

{formatTimestamp(row.original.last_seen_at)}

-
- ), - }, - { - accessorKey: "updated_at", - header: "Updated", - cell: ({ row }) => ( - - {formatTimestamp(row.original.updated_at)} - - ), - }, - { - id: "actions", - header: "", - cell: ({ row }) => ( -
event.stopPropagation()} - > - { + const resolveBoardName = (agent: Agent) => + boards.find((board) => board.id === agent.board_id)?.name ?? "—"; + + return [ + { + accessorKey: "name", + header: "Agent", + cell: ({ row }) => ( +
+

{row.original.name}

+

ID {row.original.id}

+
+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => , + }, + { + accessorKey: "openclaw_session_id", + header: "Session", + cell: ({ row }) => ( + + {truncate(row.original.openclaw_session_id)} + + ), + }, + { + accessorKey: "board_id", + header: "Board", + cell: ({ row }) => ( + + {resolveBoardName(row.original)} + + ), + }, + { + accessorKey: "last_seen_at", + header: "Last seen", + cell: ({ row }) => ( +
+

+ {formatRelative(row.original.last_seen_at)} +

+

+ {formatTimestamp(row.original.last_seen_at)} +

+
+ ), + }, + { + accessorKey: "updated_at", + header: "Updated", + cell: ({ row }) => ( + + {formatTimestamp(row.original.updated_at)} + + ), + }, + { + id: "actions", + header: "", + cell: ({ row }) => ( +
event.stopPropagation()} > - View - - - Edit - - -
- ), - }, - ], - [] + + View + + + Edit + + +
+ ), + }, + ]; + }, + [boards] ); const table = useReactTable({ @@ -284,7 +350,11 @@ export default function AgentsPage() {

-
+ {gatewayStatus?.sessions_count ?? 0} sessions diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 2cf1a784..aad81187 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -31,6 +31,10 @@ type Board = { id: string; name: string; slug: string; + gateway_url?: string | null; + gateway_token?: string | null; + gateway_main_session_key?: string | null; + gateway_workspace_root?: string | null; }; type Task = { @@ -70,6 +74,13 @@ export default function BoardDetailPage() { const [priority, setPriority] = useState("medium"); const [createError, setCreateError] = useState(null); const [isCreating, setIsCreating] = useState(false); + const [gatewayUrl, setGatewayUrl] = useState(""); + const [gatewayToken, setGatewayToken] = useState(""); + const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState(""); + const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [saveSuccess, setSaveSuccess] = useState(false); const titleLabel = useMemo( () => (board ? `${board.name} board` : "Board"), @@ -106,6 +117,9 @@ export default function BoardDetailPage() { const taskData = (await tasksResponse.json()) as Task[]; setBoard(boardData); setTasks(taskData); + setGatewayUrl(boardData.gateway_url ?? ""); + setGatewayMainSessionKey(boardData.gateway_main_session_key ?? ""); + setGatewayWorkspaceRoot(boardData.gateway_workspace_root ?? ""); } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong."); } finally { @@ -165,6 +179,47 @@ export default function BoardDetailPage() { } }; + const handleSaveSettings = async () => { + if (!isSignedIn || !boardId) return; + setIsSaving(true); + setSaveError(null); + setSaveSuccess(false); + try { + const token = await getToken(); + const payload: Partial = { + gateway_url: gatewayUrl.trim() || null, + gateway_main_session_key: gatewayMainSessionKey.trim() || null, + gateway_workspace_root: gatewayWorkspaceRoot.trim() || null, + }; + if (gatewayToken.trim()) { + payload.gateway_token = gatewayToken.trim(); + } + const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error("Unable to update board settings."); + } + const updated = (await response.json()) as Board; + setBoard(updated); + setGatewayUrl(updated.gateway_url ?? ""); + setGatewayMainSessionKey(updated.gateway_main_session_key ?? ""); + setGatewayWorkspaceRoot(updated.gateway_workspace_root ?? ""); + setGatewayToken(""); + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 2500); + } catch (err) { + setSaveError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsSaving(false); + } + }; + return ( @@ -213,11 +268,88 @@ export default function BoardDetailPage() { Loading {titleLabel}…
) : ( - setIsDialogOpen(true)} - isCreateDisabled={isCreating} - /> + <> + setIsDialogOpen(true)} + isCreateDisabled={isCreating} + /> +
+
+

+ Gateway settings +

+

+ Connect this board to an OpenClaw gateway. +

+

+ Used when provisioning agents and checking gateway status for + this board. +

+
+
+
+ + setGatewayUrl(event.target.value)} + placeholder="ws://gateway:18789" + /> +
+
+ + setGatewayToken(event.target.value)} + placeholder="Leave blank to keep current token" + /> +
+
+ + + setGatewayMainSessionKey(event.target.value) + } + placeholder="agent:main:main" + /> +
+
+ + + setGatewayWorkspaceRoot(event.target.value) + } + placeholder="~/.openclaw/workspaces" + /> +
+
+ {saveError ? ( +
+ {saveError} +
+ ) : null} + {saveSuccess ? ( +
+ Gateway settings saved. +
+ ) : null} +
+ +
+
+ )} diff --git a/frontend/src/app/boards/new/page.tsx b/frontend/src/app/boards/new/page.tsx index b6743e1f..b08f2fa1 100644 --- a/frontend/src/app/boards/new/page.tsx +++ b/frontend/src/app/boards/new/page.tsx @@ -14,6 +14,10 @@ type Board = { id: string; name: string; slug: string; + gateway_url?: string | null; + gateway_token?: string | null; + gateway_main_session_key?: string | null; + gateway_workspace_root?: string | null; }; const apiBase = @@ -31,6 +35,10 @@ export default function NewBoardPage() { const router = useRouter(); const { getToken, isSignedIn } = useAuth(); const [name, setName] = useState(""); + const [gatewayUrl, setGatewayUrl] = useState(""); + const [gatewayToken, setGatewayToken] = useState(""); + const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState(""); + const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -43,13 +51,25 @@ export default function NewBoardPage() { setError(null); try { const token = await getToken(); + const payload: Partial = { + name: trimmed, + slug: slugify(trimmed), + }; + if (gatewayUrl.trim()) payload.gateway_url = gatewayUrl.trim(); + if (gatewayToken.trim()) payload.gateway_token = gatewayToken.trim(); + if (gatewayMainSessionKey.trim()) { + payload.gateway_main_session_key = gatewayMainSessionKey.trim(); + } + if (gatewayWorkspaceRoot.trim()) { + payload.gateway_workspace_root = gatewayWorkspaceRoot.trim(); + } const response = await fetch(`${apiBase}/api/v1/boards`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: token ? `Bearer ${token}` : "", }, - body: JSON.stringify({ name: trimmed, slug: slugify(trimmed) }), + body: JSON.stringify(payload), }); if (!response.ok) { throw new Error("Unable to create board."); @@ -103,6 +123,53 @@ export default function NewBoardPage() { disabled={isLoading} /> +
+ + setGatewayUrl(event.target.value)} + placeholder="ws://gateway:18789" + disabled={isLoading} + /> +

+ Required to provision agents for this board. +

+
+
+ + setGatewayToken(event.target.value)} + placeholder="Optional bearer token" + disabled={isLoading} + /> +
+
+ + setGatewayMainSessionKey(event.target.value)} + placeholder="agent:main:main" + disabled={isLoading} + /> +
+
+ + setGatewayWorkspaceRoot(event.target.value)} + placeholder="~/.openclaw/workspaces" + disabled={isLoading} + /> +
{error ? (
{error} diff --git a/templates/HEARTBEAT.md b/templates/HEARTBEAT.md index 987c5d40..80f7328d 100644 --- a/templates/HEARTBEAT.md +++ b/templates/HEARTBEAT.md @@ -6,6 +6,7 @@ If this file is empty, skip heartbeat work. - BASE_URL (e.g. http://localhost:8000) - AUTH_TOKEN (agent token) - AGENT_NAME +- BOARD_ID ## Schedule - Run this heartbeat every 10 minutes. @@ -17,7 +18,7 @@ If this file is empty, skip heartbeat work. curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \ -H "X-Agent-Token: $AUTH_TOKEN" \ -H "Content-Type: application/json" \ - -d '{"name": "'$AGENT_NAME'", "status": "online"}' + -d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}' ``` 2) List boards: diff --git a/templates/TOOLS.md b/templates/TOOLS.md index 513482f6..2a373caf 100644 --- a/templates/TOOLS.md +++ b/templates/TOOLS.md @@ -6,6 +6,7 @@ MAIN_SESSION_KEY={{ main_session_key }} WORKSPACE_ROOT={{ workspace_root }} AGENT_NAME={{ agent_name }} AGENT_ID={{ agent_id }} +BOARD_ID={{ board_id }} SESSION_KEY={{ session_key }} WORKSPACE_PATH={{ workspace_path }}