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 <noreply@anthropic.com>
This commit is contained in:
Abhimanyu Saharan
2026-02-04 16:04:52 +05:30
parent 12698d0781
commit 4dea771545
20 changed files with 827 additions and 196 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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)

View File

@@ -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}

View File

@@ -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 = ""

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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):

View File

@@ -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)

View File

@@ -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<Agent | null>(null);
const [name, setName] = useState("");
const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">Board</label>
<Select
value={boardId}
onValueChange={(value) => setBoardId(value)}
disabled={boards.length === 0}
>
<SelectTrigger>
<SelectValue placeholder="Select board" />
</SelectTrigger>
<SelectContent>
{boards.map((board) => (
<SelectItem key={board.id} value={board.id}>
{board.name}
</SelectItem>
))}
</SelectContent>
</Select>
{boards.length === 0 ? (
<p className="text-xs text-quiet">
Create a board before assigning agents.
</p>
) : null}
</div>
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}

View File

@@ -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<Agent | null>(null);
const [events, setEvents] = useState<ActivityEvent[]>([]);
const [boards, setBoards] = useState<Board[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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 ?? "—"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Board
</p>
<p className="mt-1 text-sm text-strong">
{boards.find((board) => board.id === agent.board_id)?.name ??
"—"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Last seen

View File

@@ -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<Board[]>([]);
const [boardId, setBoardId] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLFormElement>) => {
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}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">Board</label>
<Select
value={boardId}
onValueChange={(value) => setBoardId(value)}
disabled={boards.length === 0}
>
<SelectTrigger>
<SelectValue placeholder="Select board" />
</SelectTrigger>
<SelectContent>
{boards.map((board) => (
<SelectItem key={board.id} value={board.id}>
{board.name}
</SelectItem>
))}
</SelectContent>
</Select>
{boards.length === 0 ? (
<p className="text-xs text-quiet">
Create a board before adding agents.
</p>
) : null}
</div>
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}

View File

@@ -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<Agent[]>([]);
const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState("");
const [sorting, setSorting] = useState<SortingState>([
{ 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<ColumnDef<Agent>[]>(
() => [
{
accessorKey: "name",
header: "Agent",
cell: ({ row }) => (
<div>
<p className="font-medium text-strong">{row.original.name}</p>
<p className="text-xs text-quiet">ID {row.original.id}</p>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusPill status={row.original.status} />,
},
{
accessorKey: "openclaw_session_id",
header: "Session",
cell: ({ row }) => (
<span className="text-xs text-muted">
{truncate(row.original.openclaw_session_id)}
</span>
),
},
{
accessorKey: "last_seen_at",
header: "Last seen",
cell: ({ row }) => (
<div className="text-xs text-muted">
<p className="font-medium text-strong">
{formatRelative(row.original.last_seen_at)}
</p>
<p className="text-quiet">{formatTimestamp(row.original.last_seen_at)}</p>
</div>
),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => (
<span className="text-xs text-muted">
{formatTimestamp(row.original.updated_at)}
</span>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div
className="flex items-center justify-end gap-2"
onClick={(event) => event.stopPropagation()}
>
<Link
href={`/agents/${row.original.id}`}
className="inline-flex h-8 items-center justify-center rounded-lg border border-[color:var(--border)] px-3 text-xs font-medium text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
() => {
const resolveBoardName = (agent: Agent) =>
boards.find((board) => board.id === agent.board_id)?.name ?? "—";
return [
{
accessorKey: "name",
header: "Agent",
cell: ({ row }) => (
<div>
<p className="font-medium text-strong">{row.original.name}</p>
<p className="text-xs text-quiet">ID {row.original.id}</p>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusPill status={row.original.status} />,
},
{
accessorKey: "openclaw_session_id",
header: "Session",
cell: ({ row }) => (
<span className="text-xs text-muted">
{truncate(row.original.openclaw_session_id)}
</span>
),
},
{
accessorKey: "board_id",
header: "Board",
cell: ({ row }) => (
<span className="text-xs text-muted">
{resolveBoardName(row.original)}
</span>
),
},
{
accessorKey: "last_seen_at",
header: "Last seen",
cell: ({ row }) => (
<div className="text-xs text-muted">
<p className="font-medium text-strong">
{formatRelative(row.original.last_seen_at)}
</p>
<p className="text-quiet">
{formatTimestamp(row.original.last_seen_at)}
</p>
</div>
),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => (
<span className="text-xs text-muted">
{formatTimestamp(row.original.updated_at)}
</span>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div
className="flex items-center justify-end gap-2"
onClick={(event) => event.stopPropagation()}
>
View
</Link>
<Link
href={`/agents/${row.original.id}/edit`}
className="inline-flex h-8 items-center justify-center rounded-lg border border-[color:var(--border)] px-3 text-xs font-medium text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Edit
</Link>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTarget(row.original)}
>
Delete
</Button>
</div>
),
},
],
[]
<Link
href={`/agents/${row.original.id}`}
className="inline-flex h-8 items-center justify-center rounded-lg border border-[color:var(--border)] px-3 text-xs font-medium text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
View
</Link>
<Link
href={`/agents/${row.original.id}/edit`}
className="inline-flex h-8 items-center justify-center rounded-lg border border-[color:var(--border)] px-3 text-xs font-medium text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Edit
</Link>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTarget(row.original)}
>
Delete
</Button>
</div>
),
},
];
},
[boards]
);
const table = useReactTable({
@@ -284,7 +350,11 @@ export default function AgentsPage() {
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={loadAgents} disabled={isLoading}>
<Button
variant="outline"
onClick={handleRefresh}
disabled={isLoading}
>
Refresh
</Button>
<Button onClick={() => router.push("/agents/new")}>
@@ -360,6 +430,22 @@ export default function AgentsPage() {
</p>
</div>
<div className="flex items-center gap-3">
<Select
value={boardId}
onValueChange={(value) => setBoardId(value)}
disabled={boards.length === 0}
>
<SelectTrigger className="h-8 w-[200px]">
<SelectValue placeholder="Select board" />
</SelectTrigger>
<SelectContent>
{boards.map((board) => (
<SelectItem key={board.id} value={board.id}>
{board.name}
</SelectItem>
))}
</SelectContent>
</Select>
<StatusPill status={gatewayStatus?.connected ? "online" : "offline"} />
<span className="text-xs text-quiet">
{gatewayStatus?.sessions_count ?? 0} sessions

View File

@@ -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<string | null>(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<string | null>(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<Board> = {
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 (
<DashboardShell>
<SignedOut>
@@ -213,11 +268,88 @@ export default function BoardDetailPage() {
Loading {titleLabel}
</div>
) : (
<TaskBoard
tasks={tasks}
onCreateTask={() => setIsDialogOpen(true)}
isCreateDisabled={isCreating}
/>
<>
<TaskBoard
tasks={tasks}
onCreateTask={() => setIsDialogOpen(true)}
isCreateDisabled={isCreating}
/>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6">
<div className="mb-4 space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
Gateway settings
</p>
<h2 className="text-lg font-semibold text-strong">
Connect this board to an OpenClaw gateway.
</h2>
<p className="text-sm text-muted">
Used when provisioning agents and checking gateway status for
this board.
</p>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Gateway URL
</label>
<Input
value={gatewayUrl}
onChange={(event) => setGatewayUrl(event.target.value)}
placeholder="ws://gateway:18789"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Gateway token
</label>
<Input
value={gatewayToken}
onChange={(event) => setGatewayToken(event.target.value)}
placeholder="Leave blank to keep current token"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Main session key
</label>
<Input
value={gatewayMainSessionKey}
onChange={(event) =>
setGatewayMainSessionKey(event.target.value)
}
placeholder="agent:main:main"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Workspace root
</label>
<Input
value={gatewayWorkspaceRoot}
onChange={(event) =>
setGatewayWorkspaceRoot(event.target.value)
}
placeholder="~/.openclaw/workspaces"
/>
</div>
</div>
{saveError ? (
<div className="mt-4 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{saveError}
</div>
) : null}
{saveSuccess ? (
<div className="mt-4 text-xs text-[color:var(--success)]">
Gateway settings saved.
</div>
) : null}
<div className="mt-4 flex justify-end">
<Button onClick={handleSaveSettings} disabled={isSaving}>
{isSaving ? "Saving…" : "Save settings"}
</Button>
</div>
</div>
</>
)}
</div>
</SignedIn>

View File

@@ -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<string | null>(null);
@@ -43,13 +51,25 @@ export default function NewBoardPage() {
setError(null);
try {
const token = await getToken();
const payload: Partial<Board> = {
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}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Gateway URL
</label>
<Input
value={gatewayUrl}
onChange={(event) => setGatewayUrl(event.target.value)}
placeholder="ws://gateway:18789"
disabled={isLoading}
/>
<p className="text-xs text-quiet">
Required to provision agents for this board.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Gateway token
</label>
<Input
value={gatewayToken}
onChange={(event) => setGatewayToken(event.target.value)}
placeholder="Optional bearer token"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Main session key
</label>
<Input
value={gatewayMainSessionKey}
onChange={(event) => setGatewayMainSessionKey(event.target.value)}
placeholder="agent:main:main"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Workspace root
</label>
<Input
value={gatewayWorkspaceRoot}
onChange={(event) => setGatewayWorkspaceRoot(event.target.value)}
placeholder="~/.openclaw/workspaces"
disabled={isLoading}
/>
</div>
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}

View File

@@ -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:

View File

@@ -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 }}