diff --git a/backend/alembic/versions/2b4c2f7b3eda_add_agent_heartbeat_config_column.py b/backend/alembic/versions/2b4c2f7b3eda_add_agent_heartbeat_config_column.py
new file mode 100644
index 00000000..dec703c4
--- /dev/null
+++ b/backend/alembic/versions/2b4c2f7b3eda_add_agent_heartbeat_config_column.py
@@ -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")
diff --git a/backend/alembic/versions/69858cb75533_add_agent_heartbeat_config.py b/backend/alembic/versions/69858cb75533_add_agent_heartbeat_config.py
new file mode 100644
index 00000000..516f179a
--- /dev/null
+++ b/backend/alembic/versions/69858cb75533_add_agent_heartbeat_config.py
@@ -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"
+ )
diff --git a/backend/alembic/versions/cefef25d4634_ensure_heartbeat_config_column.py b/backend/alembic/versions/cefef25d4634_ensure_heartbeat_config_column.py
new file mode 100644
index 00000000..f077f7a7
--- /dev/null
+++ b/backend/alembic/versions/cefef25d4634_ensure_heartbeat_config_column.py
@@ -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"
+ )
diff --git a/backend/alembic/versions/e0f28e965fa5_add_agent_delete_confirmation.py b/backend/alembic/versions/e0f28e965fa5_add_agent_delete_confirmation.py
new file mode 100644
index 00000000..4092d4fc
--- /dev/null
+++ b/backend/alembic/versions/e0f28e965fa5_add_agent_delete_confirmation.py
@@ -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"
+ )
diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py
index 7ff542e7..f6150c7e 100644
--- a/backend/app/api/agents.py
+++ b/backend/app/api/agents.py
@@ -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}
diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py
index e5f625ad..77f8db34 100644
--- a/backend/app/api/gateway.py
+++ b/backend/app/api/gateway.py
@@ -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,
+ }
diff --git a/backend/app/integrations/openclaw_gateway.py b/backend/app/integrations/openclaw_gateway.py
index 74b98847..aff18c5f 100644
--- a/backend/app/integrations/openclaw_gateway.py
+++ b/backend/app/integrations/openclaw_gateway.py
@@ -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
diff --git a/backend/app/integrations/openclaw_gateway_protocol.py b/backend/app/integrations/openclaw_gateway_protocol.py
new file mode 100644
index 00000000..c9d2f841
--- /dev/null
+++ b/backend/app/integrations/openclaw_gateway_protocol.py
@@ -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
diff --git a/backend/app/models/agents.py b/backend/app/models/agents.py
index 7fdbeff2..11cf7383 100644
--- a/backend/app/models/agents.py
+++ b/backend/app/models/agents.py
@@ -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)
diff --git a/backend/app/schemas/agents.py b/backend/app/schemas/agents.py
index ff4a30c2..b72192f2 100644
--- a/backend/app/schemas/agents.py
+++ b/backend/app/schemas/agents.py
@@ -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
diff --git a/backend/app/services/agent_provisioning.py b/backend/app/services/agent_provisioning.py
index 510c7224..58852d75 100644
--- a/backend/app/services/agent_provisioning.py
+++ b/backend/app/services/agent_provisioning.py
@@ -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-
+ Set how often this agent runs HEARTBEAT.md. +
++ Set how often this agent runs HEARTBEAT.md (e.g. 10m, 30m, 2h). +
+