feat(agents): Add heartbeat configuration and delete confirmation for agents

This commit is contained in:
Abhimanyu Saharan
2026-02-04 17:05:58 +05:30
parent ddad2ddb72
commit 8aa96ca876
16 changed files with 646 additions and 102 deletions

View File

@@ -0,0 +1,27 @@
"""add agent heartbeat config column
Revision ID: 2b4c2f7b3eda
Revises: 69858cb75533
Create Date: 2026-02-04 16:36:55.587762
"""
from __future__ import annotations
from alembic import op
# revision identifiers, used by Alembic.
revision = '2b4c2f7b3eda'
down_revision = '69858cb75533'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"ALTER TABLE agents ADD COLUMN IF NOT EXISTS heartbeat_config JSON"
)
def downgrade() -> None:
op.execute("ALTER TABLE agents DROP COLUMN IF EXISTS heartbeat_config")

View File

@@ -0,0 +1,29 @@
"""add agent heartbeat config
Revision ID: 69858cb75533
Revises: f1a2b3c4d5e6
Create Date: 2026-02-04 16:32:42.028772
"""
from __future__ import annotations
from alembic import op
# revision identifiers, used by Alembic.
revision = '69858cb75533'
down_revision = 'f1a2b3c4d5e6'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"ALTER TABLE agents ADD COLUMN IF NOT EXISTS heartbeat_config JSON"
)
def downgrade() -> None:
op.execute(
"ALTER TABLE agents DROP COLUMN IF EXISTS heartbeat_config"
)

View File

@@ -0,0 +1,29 @@
"""ensure heartbeat config column
Revision ID: cefef25d4634
Revises: 2b4c2f7b3eda
Create Date: 2026-02-04 16:38:25.234627
"""
from __future__ import annotations
from alembic import op
# revision identifiers, used by Alembic.
revision = 'cefef25d4634'
down_revision = '2b4c2f7b3eda'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"ALTER TABLE agents ADD COLUMN IF NOT EXISTS heartbeat_config JSON"
)
def downgrade() -> None:
op.execute(
"ALTER TABLE agents DROP COLUMN IF EXISTS heartbeat_config"
)

View File

@@ -0,0 +1,35 @@
"""add agent delete confirmation
Revision ID: e0f28e965fa5
Revises: cefef25d4634
Create Date: 2026-02-04 16:55:33.389505
"""
from __future__ import annotations
from alembic import op
# revision identifiers, used by Alembic.
revision = 'e0f28e965fa5'
down_revision = 'cefef25d4634'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"ALTER TABLE agents ADD COLUMN IF NOT EXISTS delete_requested_at TIMESTAMP"
)
op.execute(
"ALTER TABLE agents ADD COLUMN IF NOT EXISTS delete_confirm_token_hash VARCHAR"
)
def downgrade() -> None:
op.execute(
"ALTER TABLE agents DROP COLUMN IF EXISTS delete_confirm_token_hash"
)
op.execute(
"ALTER TABLE agents DROP COLUMN IF EXISTS delete_requested_at"
)

View File

@@ -9,13 +9,13 @@ from sqlmodel import Session, col, select
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.agent_tokens import generate_agent_token, hash_agent_token, verify_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,
send_message,
)
@@ -24,13 +24,18 @@ from app.models.activity_events import ActivityEvent
from app.models.boards import Board
from app.schemas.agents import (
AgentCreate,
AgentDeleteConfirm,
AgentHeartbeat,
AgentHeartbeatCreate,
AgentRead,
AgentUpdate,
)
from app.services.activity_log import record_activity
from app.services.agent_provisioning import send_provisioning_message
from app.services.agent_provisioning import (
DEFAULT_HEARTBEAT_CONFIG,
send_provisioning_message,
send_update_message,
)
router = APIRouter(prefix="/agents", tags=["agents"])
@@ -82,6 +87,8 @@ async def _ensure_gateway_session(
def _with_computed_status(agent: Agent) -> Agent:
now = datetime.utcnow()
if agent.status == "deleting":
return agent
if agent.last_seen_at is None:
agent.status = "provisioning"
elif now - agent.last_seen_at > OFFLINE_AFTER:
@@ -98,11 +105,14 @@ def _record_heartbeat(session: Session, agent: Agent) -> None:
)
def _record_provisioning_failure(session: Session, agent: Agent, error: str) -> None:
def _record_instruction_failure(
session: Session, agent: Agent, error: str, action: str
) -> None:
action_label = action.replace("_", " ").capitalize()
record_activity(
session,
event_type="agent.provision.failed",
message=f"Provisioning message failed: {error}",
event_type=f"agent.{action}.failed",
message=f"{action_label} message failed: {error}",
agent_id=agent.id,
)
@@ -116,10 +126,12 @@ def _record_wakeup_failure(session: Session, agent: Agent, error: str) -> None:
)
async def _send_wakeup_message(agent: Agent, config: GatewayConfig) -> None:
async def _send_wakeup_message(
agent: Agent, config: GatewayConfig, verb: str = "provisioned"
) -> None:
session_key = agent.openclaw_session_id or _build_session_key(agent.name)
message = (
f"Hello {agent.name}. Your workspace has been provisioned.\n\n"
f"Hello {agent.name}. Your workspace has been {verb}.\n\n"
"Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run it once "
"then delete it. Begin heartbeats after startup."
)
@@ -147,6 +159,8 @@ async def create_agent(
agent.status = "provisioning"
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
if agent.heartbeat_config is None:
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
session_key, session_error = await _ensure_gateway_session(agent.name, config)
agent.openclaw_session_id = session_key
session.add(agent)
@@ -177,11 +191,11 @@ async def create_agent(
agent_id=agent.id,
)
except OpenClawGatewayError as exc:
_record_provisioning_failure(session, agent, str(exc))
_record_instruction_failure(session, agent, str(exc), "provision")
_record_wakeup_failure(session, agent, str(exc))
session.commit()
except Exception as exc: # pragma: no cover - unexpected provisioning errors
_record_provisioning_failure(session, agent, str(exc))
_record_instruction_failure(session, agent, str(exc), "provision")
_record_wakeup_failure(session, agent, str(exc))
session.commit()
return agent
@@ -222,6 +236,8 @@ async def update_agent(
for key, value in updates.items():
setattr(agent, key, value)
agent.updated_at = datetime.utcnow()
if agent.heartbeat_config is None:
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
session.add(agent)
session.commit()
session.refresh(agent)
@@ -236,7 +252,7 @@ async def update_agent(
session.commit()
session.refresh(agent)
except OpenClawGatewayError as exc:
_record_provisioning_failure(session, agent, str(exc))
_record_instruction_failure(session, agent, str(exc), "update")
session.commit()
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
@@ -244,12 +260,12 @@ async def update_agent(
session.commit()
session.refresh(agent)
try:
await send_provisioning_message(agent, board, raw_token)
await _send_wakeup_message(agent, config)
await send_update_message(agent, board, raw_token)
await _send_wakeup_message(agent, config, verb="updated")
record_activity(
session,
event_type="agent.reprovisioned",
message=f"Re-provisioned agent {agent.name}.",
event_type="agent.updated",
message=f"Updated agent {agent.name}.",
agent_id=agent.id,
)
record_activity(
@@ -260,11 +276,11 @@ async def update_agent(
)
session.commit()
except OpenClawGatewayError as exc:
_record_provisioning_failure(session, agent, str(exc))
_record_instruction_failure(session, agent, str(exc), "update")
_record_wakeup_failure(session, agent, str(exc))
session.commit()
except Exception as exc: # pragma: no cover - unexpected provisioning errors
_record_provisioning_failure(session, agent, str(exc))
_record_instruction_failure(session, agent, str(exc), "update")
_record_wakeup_failure(session, agent, str(exc))
session.commit()
return _with_computed_status(agent)
@@ -307,7 +323,12 @@ async def heartbeat_or_create_agent(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
board = _require_board(session, payload.board_id)
config = _require_gateway_config(board)
agent = Agent(name=payload.name, status="provisioning", board_id=board.id)
agent = Agent(
name=payload.name,
status="provisioning",
board_id=board.id,
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
)
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
session_key, session_error = await _ensure_gateway_session(agent.name, config)
@@ -340,11 +361,11 @@ async def heartbeat_or_create_agent(
agent_id=agent.id,
)
except OpenClawGatewayError as exc:
_record_provisioning_failure(session, agent, str(exc))
_record_instruction_failure(session, agent, str(exc), "provision")
_record_wakeup_failure(session, agent, str(exc))
session.commit()
except Exception as exc: # pragma: no cover - unexpected provisioning errors
_record_provisioning_failure(session, agent, str(exc))
_record_instruction_failure(session, agent, str(exc), "provision")
_record_wakeup_failure(session, agent, str(exc))
session.commit()
elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
@@ -352,6 +373,8 @@ async def heartbeat_or_create_agent(
elif agent.agent_token_hash is None and actor.actor_type == "user":
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
if agent.heartbeat_config is None:
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
session.add(agent)
session.commit()
session.refresh(agent)
@@ -367,11 +390,11 @@ async def heartbeat_or_create_agent(
agent_id=agent.id,
)
except OpenClawGatewayError as exc:
_record_provisioning_failure(session, agent, str(exc))
_record_instruction_failure(session, agent, str(exc), "provision")
_record_wakeup_failure(session, agent, str(exc))
session.commit()
except Exception as exc: # pragma: no cover - unexpected provisioning errors
_record_provisioning_failure(session, agent, str(exc))
_record_instruction_failure(session, agent, str(exc), "provision")
_record_wakeup_failure(session, agent, str(exc))
session.commit()
elif not agent.openclaw_session_id:
@@ -414,51 +437,104 @@ def delete_agent(
auth: AuthContext = Depends(require_admin_auth),
) -> 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, config=config)
main_session = board.gateway_main_session_key or "agent:main:main"
if main_session:
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"
f"Agent name: {agent.name}\n"
f"Agent id: {agent.id}\n"
f"Session key: {agent.openclaw_session_id or _build_session_key(agent.name)}\n"
f"Workspace path: {workspace_path}\n\n"
"Actions:\n"
"1) Remove the workspace directory.\n"
"2) Delete any lingering session artifacts.\n"
"Reply NO_REPLY."
)
await ensure_session(main_session, config=config, label="Main Agent")
await send_message(
cleanup_message,
session_key=main_session,
config=config,
deliver=False,
)
if agent is None:
return {"ok": True}
if agent.status == "deleting" and agent.delete_confirm_token_hash:
return {"ok": True}
try:
import asyncio
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
config = _require_gateway_config(board)
raw_token = generate_agent_token()
agent.delete_confirm_token_hash = hash_agent_token(raw_token)
agent.delete_requested_at = datetime.utcnow()
agent.status = "deleting"
agent.updated_at = datetime.utcnow()
session.add(agent)
record_activity(
session,
event_type="agent.delete.requested",
message=f"Delete requested for {agent.name}.",
agent_id=agent.id,
)
session.commit()
asyncio.run(_gateway_cleanup())
except OpenClawGatewayError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Gateway cleanup failed: {exc}",
) from exc
session.execute(
update(ActivityEvent)
.where(col(ActivityEvent.agent_id) == agent.id)
.values(agent_id=None)
async def _gateway_cleanup_request() -> None:
main_session = board.gateway_main_session_key or "agent:main:main"
if not main_session:
return
workspace_root = board.gateway_workspace_root or "~/.openclaw/workspaces"
workspace_path = f"{workspace_root.rstrip('/')}/{_slugify(agent.name)}"
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
cleanup_message = (
"Cleanup request for deleted agent.\n\n"
f"Agent name: {agent.name}\n"
f"Agent id: {agent.id}\n"
f"Session key: {agent.openclaw_session_id or _build_session_key(agent.name)}\n"
f"Workspace path: {workspace_path}\n\n"
"Actions:\n"
"1) Remove the workspace directory.\n"
"2) Delete the agent session from the gateway.\n"
"3) Confirm deletion by calling:\n"
f" POST {base_url}/api/v1/agents/{agent.id}/delete/confirm\n"
" Body: {\"token\": \"" + raw_token + "\"}\n"
"Reply NO_REPLY."
)
session.delete(agent)
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
asyncio.run(_gateway_cleanup_request())
except OpenClawGatewayError as exc:
_record_instruction_failure(session, agent, str(exc), "delete")
session.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Gateway cleanup request failed: {exc}",
) from exc
return {"ok": True}
@router.post("/{agent_id}/delete/confirm")
def confirm_delete_agent(
agent_id: str,
payload: AgentDeleteConfirm,
session: Session = Depends(get_session),
) -> dict[str, bool]:
agent = session.get(Agent, agent_id)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if agent.status != "deleting":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Agent is not pending deletion.",
)
if not agent.delete_confirm_token_hash:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Delete confirmation not requested.",
)
if not verify_agent_token(payload.token, agent.delete_confirm_token_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token.")
record_activity(
session,
event_type="agent.delete.confirmed",
message=f"Deleted agent {agent.name}.",
agent_id=None,
)
session.execute(
update(ActivityEvent)
.where(col(ActivityEvent.agent_id) == agent.id)
.values(agent_id=None)
)
session.delete(agent)
session.commit()
return {"ok": True}

View File

@@ -13,6 +13,11 @@ from app.integrations.openclaw_gateway import (
openclaw_call,
send_message,
)
from app.integrations.openclaw_gateway_protocol import (
GATEWAY_EVENTS,
GATEWAY_METHODS,
PROTOCOL_VERSION,
)
from app.db.session import get_session
from app.models.boards import Board
@@ -196,3 +201,14 @@ async def send_session_message(
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
return {"ok": True}
@router.get("/commands")
async def gateway_commands(
auth: AuthContext = Depends(require_admin_auth),
) -> dict[str, object]:
return {
"protocol_version": PROTOCOL_VERSION,
"methods": GATEWAY_METHODS,
"events": GATEWAY_EVENTS,
}

View File

@@ -9,6 +9,7 @@ from uuid import uuid4
import websockets
from app.integrations.openclaw_gateway_protocol import PROTOCOL_VERSION
class OpenClawGatewayError(RuntimeError):
@@ -64,23 +65,10 @@ async def _send_request(
return await _await_response(ws, request_id)
async def _handle_challenge(
ws: websockets.WebSocketClientProtocol,
first_message: str | bytes | None,
config: GatewayConfig,
) -> None:
if not first_message:
return
if isinstance(first_message, bytes):
first_message = first_message.decode("utf-8")
data = json.loads(first_message)
if data.get("type") != "event" or data.get("event") != "connect.challenge":
return
connect_id = str(uuid4())
def _build_connect_params(config: GatewayConfig) -> dict[str, Any]:
params: dict[str, Any] = {
"minProtocol": 3,
"maxProtocol": 3,
"minProtocol": PROTOCOL_VERSION,
"maxProtocol": PROTOCOL_VERSION,
"client": {
"id": "gateway-client",
"version": "1.0.0",
@@ -90,11 +78,26 @@ async def _handle_challenge(
}
if config.token:
params["auth"] = {"token": config.token}
return params
async def _ensure_connected(
ws: websockets.WebSocketClientProtocol,
first_message: str | bytes | None,
config: GatewayConfig,
) -> None:
if first_message:
if isinstance(first_message, bytes):
first_message = first_message.decode("utf-8")
data = json.loads(first_message)
if data.get("type") != "event" or data.get("event") != "connect.challenge":
pass
connect_id = str(uuid4())
response = {
"type": "req",
"id": connect_id,
"method": "connect",
"params": params,
"params": _build_connect_params(config),
}
await ws.send(json.dumps(response))
await _await_response(ws, connect_id)
@@ -114,7 +117,7 @@ async def openclaw_call(
first_message = await asyncio.wait_for(ws.recv(), timeout=2)
except asyncio.TimeoutError:
first_message = None
await _handle_challenge(ws, first_message, config)
await _ensure_connected(ws, first_message, config)
return await _send_request(ws, method, params)
except OpenClawGatewayError:
raise

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
PROTOCOL_VERSION = 3
# NOTE: These are the base gateway methods from the OpenClaw gateway repo.
# The gateway can expose additional methods at runtime via channel plugins.
GATEWAY_METHODS = [
"health",
"logs.tail",
"channels.status",
"channels.logout",
"status",
"usage.status",
"usage.cost",
"tts.status",
"tts.providers",
"tts.enable",
"tts.disable",
"tts.convert",
"tts.setProvider",
"config.get",
"config.set",
"config.apply",
"config.patch",
"config.schema",
"exec.approvals.get",
"exec.approvals.set",
"exec.approvals.node.get",
"exec.approvals.node.set",
"exec.approval.request",
"exec.approval.resolve",
"wizard.start",
"wizard.next",
"wizard.cancel",
"wizard.status",
"talk.mode",
"models.list",
"agents.list",
"agents.files.list",
"agents.files.get",
"agents.files.set",
"skills.status",
"skills.bins",
"skills.install",
"skills.update",
"update.run",
"voicewake.get",
"voicewake.set",
"sessions.list",
"sessions.preview",
"sessions.patch",
"sessions.reset",
"sessions.delete",
"sessions.compact",
"last-heartbeat",
"set-heartbeats",
"wake",
"node.pair.request",
"node.pair.list",
"node.pair.approve",
"node.pair.reject",
"node.pair.verify",
"device.pair.list",
"device.pair.approve",
"device.pair.reject",
"device.token.rotate",
"device.token.revoke",
"node.rename",
"node.list",
"node.describe",
"node.invoke",
"node.invoke.result",
"node.event",
"cron.list",
"cron.status",
"cron.add",
"cron.update",
"cron.remove",
"cron.run",
"cron.runs",
"system-presence",
"system-event",
"send",
"agent",
"agent.identity.get",
"agent.wait",
"browser.request",
"chat.history",
"chat.abort",
"chat.send",
]
GATEWAY_EVENTS = [
"connect.challenge",
"agent",
"chat",
"presence",
"tick",
"talk.mode",
"shutdown",
"health",
"heartbeat",
"cron",
"node.pair.requested",
"node.pair.resolved",
"node.invoke.request",
"device.pair.requested",
"device.pair.resolved",
"voicewake.changed",
"exec.approval.requested",
"exec.approval.resolved",
]
GATEWAY_METHODS_SET = frozenset(GATEWAY_METHODS)
GATEWAY_EVENTS_SET = frozenset(GATEWAY_EVENTS)
def is_known_gateway_method(method: str) -> bool:
return method in GATEWAY_METHODS_SET

View File

@@ -1,8 +1,10 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import Column, JSON
from sqlmodel import Field, SQLModel
@@ -15,6 +17,11 @@ class Agent(SQLModel, table=True):
status: str = Field(default="provisioning", index=True)
openclaw_session_id: str | None = Field(default=None, index=True)
agent_token_hash: str | None = Field(default=None, index=True)
heartbeat_config: dict[str, Any] | None = Field(
default=None, sa_column=Column(JSON)
)
delete_requested_at: datetime | None = Field(default=None)
delete_confirm_token_hash: str | None = Field(default=None, index=True)
last_seen_at: datetime | None = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID
from sqlmodel import SQLModel
@@ -10,6 +11,7 @@ class AgentBase(SQLModel):
board_id: UUID | None = None
name: str
status: str = "provisioning"
heartbeat_config: dict[str, Any] | None = None
class AgentCreate(AgentBase):
@@ -20,6 +22,7 @@ class AgentUpdate(SQLModel):
board_id: UUID | None = None
name: str | None = None
status: str | None = None
heartbeat_config: dict[str, Any] | None = None
class AgentRead(AgentBase):
@@ -37,3 +40,7 @@ class AgentHeartbeat(SQLModel):
class AgentHeartbeatCreate(AgentHeartbeat):
name: str
board_id: UUID | None = None
class AgentDeleteConfirm(SQLModel):
token: str

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from uuid import uuid4
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
@@ -22,6 +24,8 @@ TEMPLATE_FILES = [
"USER.md",
]
DEFAULT_HEARTBEAT_CONFIG = {"every": "10m", "target": "none"}
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
@@ -36,6 +40,21 @@ def _slugify(value: str) -> str:
return slug or uuid4().hex
def _agent_key(agent: Agent) -> str:
session_key = agent.openclaw_session_id or ""
if session_key.startswith("agent:"):
parts = session_key.split(":")
if len(parts) >= 2 and parts[1]:
return parts[1]
return _slugify(agent.name)
def _heartbeat_config(agent: Agent) -> dict[str, Any]:
if agent.heartbeat_config:
return agent.heartbeat_config
return DEFAULT_HEARTBEAT_CONFIG.copy()
def _template_env() -> Environment:
return Environment(
loader=FileSystemLoader(_templates_root()),
@@ -69,15 +88,14 @@ def _workspace_path(agent_name: str, workspace_root: str) -> str:
return f"{root}/{_slugify(agent_name)}"
def build_provisioning_message(agent: Agent, board: Board, auth_token: str) -> str:
def _build_context(agent: Agent, board: Board, auth_token: str) -> dict[str, str]:
agent_id = str(agent.id)
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 = {
return {
"agent_name": agent.name,
"agent_id": agent_id,
"board_id": str(board.id),
@@ -93,20 +111,34 @@ def build_provisioning_message(agent: Agent, board: Board, auth_token: str) -> s
"user_notes": "Fill in user context.",
}
templates = _read_templates(context)
file_blocks = "".join(
def _build_file_blocks(context: dict[str, str]) -> str:
templates = _read_templates(context)
return "".join(
_render_file_block(name, templates.get(name, "")) for name in TEMPLATE_FILES
)
def build_provisioning_message(agent: Agent, board: Board, auth_token: str) -> str:
context = _build_context(agent, board, auth_token)
file_blocks = _build_file_blocks(context)
heartbeat_snippet = json.dumps(
{
"id": _agent_key(agent),
"workspace": context["workspace_path"],
"heartbeat": _heartbeat_config(agent),
},
indent=2,
sort_keys=True,
)
return (
"Provision a new OpenClaw agent workspace.\n\n"
f"Agent name: {agent.name}\n"
f"Agent id: {agent_id}\n"
f"Session key: {session_key}\n"
f"Workspace path: {workspace_path}\n\n"
f"Base URL: {base_url}\n"
f"Auth token: {auth_token}\n\n"
f"Agent name: {context['agent_name']}\n"
f"Agent id: {context['agent_id']}\n"
f"Session key: {context['session_key']}\n"
f"Workspace path: {context['workspace_path']}\n\n"
f"Base URL: {context['base_url']}\n"
f"Auth token: {context['auth_token']}\n\n"
"Steps:\n"
"0) IMPORTANT: Do NOT replace or repurpose the main agent. Keep "
f"{context['main_session_key']} unchanged and its workspace intact.\n"
@@ -115,7 +147,56 @@ def build_provisioning_message(agent: Agent, board: Board, auth_token: str) -> s
"3) Update TOOLS.md if BASE_URL/AUTH_TOKEN must change.\n"
"4) Leave BOOTSTRAP.md in place; the agent should run it on first start and delete it.\n"
"5) Register agent id in OpenClaw so it uses this workspace path "
"(never overwrite the main agent session).\n\n"
"(never overwrite the main agent session).\n"
" IMPORTANT: Do NOT use ~/.openclaw/workspace-<name>. The canonical path "
"is ~/.openclaw/workspaces/<slug>.\n"
"6) Add/update the per-agent heartbeat config in the gateway config "
"for this agent (merge into agents.list entry):\n"
"```json\n"
f"{heartbeat_snippet}\n"
"```\n"
"Note: if any agents.list entry defines heartbeat, only those agents "
"run heartbeats.\n\n"
"Files:" + file_blocks
)
def build_update_message(agent: Agent, board: Board, auth_token: str) -> str:
context = _build_context(agent, board, auth_token)
file_blocks = _build_file_blocks(context)
heartbeat_snippet = json.dumps(
{
"id": _agent_key(agent),
"workspace": context["workspace_path"],
"heartbeat": _heartbeat_config(agent),
},
indent=2,
sort_keys=True,
)
return (
"Update an existing OpenClaw agent workspace.\n\n"
f"Agent name: {context['agent_name']}\n"
f"Agent id: {context['agent_id']}\n"
f"Session key: {context['session_key']}\n"
f"Workspace path: {context['workspace_path']}\n\n"
f"Base URL: {context['base_url']}\n"
f"Auth token: {context['auth_token']}\n\n"
"Steps:\n"
"0) IMPORTANT: Do NOT replace or repurpose the main agent. Keep "
f"{context['main_session_key']} unchanged and its workspace intact.\n"
"1) Locate the existing workspace directory (do NOT create a new one or change its path).\n"
"2) Overwrite the files below with the exact contents.\n"
"3) Update TOOLS.md with the new BASE_URL/AUTH_TOKEN/SESSION_KEY values.\n"
"4) Do NOT create a new agent or session; update the existing one in place.\n"
"5) Keep BOOTSTRAP.md only if it already exists; do not recreate it if missing.\n\n"
" IMPORTANT: Do NOT use ~/.openclaw/workspace-<name>. The canonical path "
"is ~/.openclaw/workspaces/<slug>.\n"
"6) Update the per-agent heartbeat config in the gateway config for this agent:\n"
"```json\n"
f"{heartbeat_snippet}\n"
"```\n"
"Note: if any agents.list entry defines heartbeat, only those agents "
"run heartbeats.\n\n"
"Files:" + file_blocks
)
@@ -132,3 +213,17 @@ async def send_provisioning_message(
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)
async def send_update_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
config = GatewayConfig(url=board.gateway_url, token=board.gateway_token)
await ensure_session(main_session, config=config, label="Main Agent")
message = build_update_message(agent, board, auth_token)
await send_message(message, session_key=main_session, config=config, deliver=False)

View File

@@ -25,6 +25,10 @@ type Agent = {
id: string;
name: string;
board_id?: string | null;
heartbeat_config?: {
every?: string;
target?: string;
} | null;
};
type Board = {
@@ -44,6 +48,8 @@ export default function EditAgentPage() {
const [name, setName] = useState("");
const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState("");
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -59,9 +65,6 @@ export default function EditAgentPage() {
}
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.");
}
@@ -85,6 +88,12 @@ export default function EditAgentPage() {
if (data.board_id) {
setBoardId(data.board_id);
}
if (data.heartbeat_config?.every) {
setHeartbeatEvery(data.heartbeat_config.every);
}
if (data.heartbeat_config?.target) {
setHeartbeatTarget(data.heartbeat_config.target);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
@@ -98,6 +107,17 @@ export default function EditAgentPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn, agentId]);
useEffect(() => {
if (boardId) return;
if (agent?.board_id) {
setBoardId(agent.board_id);
return;
}
if (boards.length > 0) {
setBoardId(boards[0].id);
}
}, [agent, boards, boardId]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn || !agentId) return;
@@ -120,7 +140,14 @@ export default function EditAgentPage() {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ name: trimmed, board_id: boardId }),
body: JSON.stringify({
name: trimmed,
board_id: boardId,
heartbeat_config: {
every: heartbeatEvery.trim() || "10m",
target: heartbeatTarget,
},
}),
});
if (!response.ok) {
throw new Error("Unable to update agent.");
@@ -195,6 +222,38 @@ export default function EditAgentPage() {
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Heartbeat interval
</label>
<Input
value={heartbeatEvery}
onChange={(event) => setHeartbeatEvery(event.target.value)}
placeholder="e.g. 10m"
disabled={isLoading}
/>
<p className="text-xs text-quiet">
Set how often this agent runs HEARTBEAT.md.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Heartbeat target
</label>
<Select
value={heartbeatTarget}
onValueChange={(value) => setHeartbeatTarget(value)}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Select target" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (no outbound message)</SelectItem>
<SelectItem value="last">Last channel</SelectItem>
</SelectContent>
</Select>
</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

@@ -39,6 +39,8 @@ export default function NewAgentPage() {
const [name, setName] = useState("");
const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState<string>("");
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -89,7 +91,14 @@ export default function NewAgentPage() {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ name: trimmed, board_id: boardId }),
body: JSON.stringify({
name: trimmed,
board_id: boardId,
heartbeat_config: {
every: heartbeatEvery.trim() || "10m",
target: heartbeatTarget,
},
}),
});
if (!response.ok) {
throw new Error("Unable to create agent.");
@@ -165,6 +174,38 @@ export default function NewAgentPage() {
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Heartbeat interval
</label>
<Input
value={heartbeatEvery}
onChange={(event) => setHeartbeatEvery(event.target.value)}
placeholder="e.g. 10m"
disabled={isLoading}
/>
<p className="text-xs text-quiet">
Set how often this agent runs HEARTBEAT.md (e.g. 10m, 30m, 2h).
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Heartbeat target
</label>
<Select
value={heartbeatTarget}
onValueChange={(value) => setHeartbeatTarget(value)}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Select target" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (no outbound message)</SelectItem>
<SelectItem value="last">Last channel</SelectItem>
</SelectContent>
</Select>
</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

@@ -204,7 +204,7 @@ export default function AgentsPage() {
if (!response.ok) {
throw new Error("Unable to delete agent.");
}
setAgents((prev) => prev.filter((agent) => agent.id !== deleteTarget.id));
await loadAgents();
setDeleteTarget(null);
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "Something went wrong.");

View File

@@ -14,6 +14,7 @@ const STATUS_STYLES: Record<
busy: "warning",
provisioning: "warning",
offline: "outline",
deleting: "danger",
};
export function StatusPill({ status }: { status: string }) {

View File

@@ -9,7 +9,7 @@ If this file is empty, skip heartbeat work.
- BOARD_ID
## Schedule
- Run this heartbeat every 10 minutes.
- Schedule is controlled by gateway heartbeat config (default: every 10 minutes).
- On first boot, send one immediate check-in before the schedule starts.
## On every heartbeat