diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index a09015ab..46052a69 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -11,7 +11,6 @@ from sqlmodel import Session, col, select 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, 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 as GatewayClientConfig from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message @@ -31,8 +30,8 @@ from app.schemas.agents import ( from app.services.activity_log import record_activity from app.services.agent_provisioning import ( DEFAULT_HEARTBEAT_CONFIG, - send_provisioning_message, - send_update_message, + cleanup_agent_direct, + provision_agent, ) router = APIRouter(prefix="/agents", tags=["agents"]) @@ -194,8 +193,6 @@ async def create_agent( agent.agent_token_hash = hash_agent_token(raw_token) if agent.heartbeat_config is None: agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy() - provision_token = generate_agent_token() - agent.provision_confirm_token_hash = hash_agent_token(provision_token) agent.provision_requested_at = datetime.utcnow() agent.provision_action = "provision" session_key, session_error = await _ensure_gateway_session(agent.name, client_config) @@ -219,15 +216,27 @@ async def create_agent( ) session.commit() try: - await send_provisioning_message( - agent, board, gateway, raw_token, provision_token, auth.user + await provision_agent(agent, board, gateway, raw_token, auth.user, action="provision") + await _send_wakeup_message(agent, client_config, verb="provisioned") + agent.provision_confirm_token_hash = None + agent.provision_requested_at = None + agent.provision_action = None + agent.updated_at = datetime.utcnow() + session.add(agent) + session.commit() + record_activity( + session, + event_type="agent.provision", + message=f"Provisioned directly for {agent.name}.", + agent_id=agent.id, ) record_activity( session, - event_type="agent.provision.requested", - message=f"Provisioning requested for {agent.name}.", + event_type="agent.wakeup.sent", + message=f"Wakeup message sent to {agent.name}.", agent_id=agent.id, ) + session.commit() except OpenClawGatewayError as exc: _record_instruction_failure(session, agent, str(exc), "provision") session.commit() @@ -295,9 +304,7 @@ async def update_agent( _record_instruction_failure(session, agent, str(exc), "update") session.commit() raw_token = generate_agent_token() - provision_token = generate_agent_token() agent.agent_token_hash = hash_agent_token(raw_token) - agent.provision_confirm_token_hash = hash_agent_token(provision_token) agent.provision_requested_at = datetime.utcnow() agent.provision_action = "update" agent.status = "updating" @@ -305,11 +312,25 @@ async def update_agent( session.commit() session.refresh(agent) try: - await send_update_message(agent, board, gateway, raw_token, provision_token, auth.user) + await provision_agent(agent, board, gateway, raw_token, auth.user, action="update") + await _send_wakeup_message(agent, client_config, verb="updated") + agent.provision_confirm_token_hash = None + agent.provision_requested_at = None + agent.provision_action = None + agent.status = "online" + agent.updated_at = datetime.utcnow() + session.add(agent) + session.commit() record_activity( session, - event_type="agent.update.requested", - message=f"Update requested for {agent.name}.", + event_type="agent.update.direct", + message=f"Updated directly for {agent.name}.", + agent_id=agent.id, + ) + record_activity( + session, + event_type="agent.wakeup.sent", + message=f"Wakeup message sent to {agent.name}.", agent_id=agent.id, ) session.commit() @@ -367,8 +388,6 @@ async def heartbeat_or_create_agent( ) raw_token = generate_agent_token() agent.agent_token_hash = hash_agent_token(raw_token) - provision_token = generate_agent_token() - agent.provision_confirm_token_hash = hash_agent_token(provision_token) agent.provision_requested_at = datetime.utcnow() agent.provision_action = "provision" session_key, session_error = await _ensure_gateway_session(agent.name, client_config) @@ -392,15 +411,27 @@ async def heartbeat_or_create_agent( ) session.commit() try: - await send_provisioning_message( - agent, board, gateway, raw_token, provision_token, actor.user + await provision_agent(agent, board, gateway, raw_token, actor.user, action="provision") + await _send_wakeup_message(agent, client_config, verb="provisioned") + agent.provision_confirm_token_hash = None + agent.provision_requested_at = None + agent.provision_action = None + agent.updated_at = datetime.utcnow() + session.add(agent) + session.commit() + record_activity( + session, + event_type="agent.provision", + message=f"Provisioned directly for {agent.name}.", + agent_id=agent.id, ) record_activity( session, - event_type="agent.provision.requested", - message=f"Provisioning requested for {agent.name}.", + event_type="agent.wakeup.sent", + message=f"Wakeup message sent to {agent.name}.", agent_id=agent.id, ) + session.commit() except OpenClawGatewayError as exc: _record_instruction_failure(session, agent, str(exc), "provision") session.commit() @@ -414,8 +445,6 @@ async def heartbeat_or_create_agent( agent.agent_token_hash = hash_agent_token(raw_token) if agent.heartbeat_config is None: agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy() - provision_token = generate_agent_token() - agent.provision_confirm_token_hash = hash_agent_token(provision_token) agent.provision_requested_at = datetime.utcnow() agent.provision_action = "provision" session.add(agent) @@ -424,15 +453,27 @@ async def heartbeat_or_create_agent( try: board = _require_board(session, str(agent.board_id) if agent.board_id else None) gateway, client_config = _require_gateway(session, board) - await send_provisioning_message( - agent, board, gateway, raw_token, provision_token, actor.user + await provision_agent(agent, board, gateway, raw_token, actor.user, action="provision") + await _send_wakeup_message(agent, client_config, verb="provisioned") + agent.provision_confirm_token_hash = None + agent.provision_requested_at = None + agent.provision_action = None + agent.updated_at = datetime.utcnow() + session.add(agent) + session.commit() + record_activity( + session, + event_type="agent.provision", + message=f"Provisioned directly for {agent.name}.", + agent_id=agent.id, ) record_activity( session, - event_type="agent.provision.requested", - message=f"Provisioning requested for {agent.name}.", + event_type="agent.wakeup.sent", + message=f"Wakeup message sent to {agent.name}.", agent_id=agent.id, ) + session.commit() except OpenClawGatewayError as exc: _record_instruction_failure(session, agent, str(exc), "provision") session.commit() @@ -485,61 +526,39 @@ def delete_agent( return {"ok": True} board = _require_board(session, str(agent.board_id) if agent.board_id else None) - gateway, client_config = _require_gateway(session, 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() - - async def _gateway_cleanup_request() -> None: - main_session = gateway.main_session_key - if not main_session: - raise OpenClawGatewayError("Gateway main_session_key is required") - workspace_path = _workspace_path(agent.name, gateway.workspace_root) - 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." - ) - await ensure_session(main_session, config=client_config, label="Main Agent") - await send_message( - cleanup_message, - session_key=main_session, - config=client_config, - deliver=False, - ) - + gateway, _ = _require_gateway(session, board) try: import asyncio - asyncio.run(_gateway_cleanup_request()) + asyncio.run(cleanup_agent_direct(agent, gateway, delete_workspace=True)) 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}", + detail=f"Gateway cleanup failed: {exc}", + ) from exc + except Exception as exc: # pragma: no cover - unexpected cleanup errors + _record_instruction_failure(session, agent, str(exc), "delete") + session.commit() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Workspace cleanup failed: {exc}", ) from exc + record_activity( + session, + event_type="agent.delete.direct", + 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/core/config.py b/backend/app/core/config.py index 75426ee6..63718fb4 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -31,4 +31,5 @@ class Settings(BaseSettings): log_use_utc: bool = False + settings = Settings() diff --git a/backend/app/services/agent_provisioning.py b/backend/app/services/agent_provisioning.py index 7d3e7188..d4138a0f 100644 --- a/backend/app/services/agent_provisioning.py +++ b/backend/app/services/agent_provisioning.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import re +import shutil from pathlib import Path from typing import Any from uuid import uuid4 @@ -10,25 +11,27 @@ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoes from app.core.config import settings from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig -from app.integrations.openclaw_gateway import ensure_session, send_message +from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, openclaw_call from app.models.agents import Agent from app.models.boards import Board from app.models.gateways import Gateway from app.models.users import User -TEMPLATE_FILES = [ - "AGENTS.md", - "BOOT.md", - "BOOTSTRAP.md", - "HEARTBEAT.md", - "IDENTITY.md", - "SOUL.md", - "TOOLS.md", - "USER.md", -] - DEFAULT_HEARTBEAT_CONFIG = {"every": "10m", "target": "none"} +DEFAULT_GATEWAY_FILES = frozenset( + { + "AGENTS.md", + "SOUL.md", + "TOOLS.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + "BOOTSTRAP.md", + "MEMORY.md", + } +) + def _repo_root() -> Path: return Path(__file__).resolve().parents[3] @@ -67,31 +70,6 @@ def _template_env() -> Environment: ) -def _read_templates( - context: dict[str, str], overrides: dict[str, str] | None = None -) -> dict[str, str]: - env = _template_env() - templates: dict[str, str] = {} - override_map = overrides or {} - for name in TEMPLATE_FILES: - path = _templates_root() / name - override = override_map.get(name) - if override: - templates[name] = env.from_string(override).render(**context).strip() - continue - if not path.exists(): - templates[name] = "" - continue - template = env.get_template(name) - templates[name] = template.render(**context).strip() - return templates - - -def _render_file_block(name: str, content: str) -> str: - body = content if content else f"# {name}\n\nTODO: add content\n" - return f"\n{name}\n```md\n{body}\n```\n" - - def _workspace_path(agent_name: str, workspace_root: str) -> str: if not workspace_root: raise ValueError("gateway_workspace_root is required") @@ -100,6 +78,16 @@ def _workspace_path(agent_name: str, workspace_root: str) -> str: return f"{root}/workspace-{_slugify(agent_name)}" +def _resolve_workspace_dir(workspace_root: str, agent_name: str) -> Path: + if not workspace_root: + raise ValueError("gateway_workspace_root is required") + root = Path(workspace_root).expanduser().resolve() + workspace = Path(_workspace_path(agent_name, workspace_root)).expanduser().resolve() + if workspace == root or root not in workspace.parents: + raise ValueError("workspace path is not under workspace root") + return workspace + + def _build_context( agent: Agent, board: Board, @@ -136,152 +124,220 @@ def _build_context( } -def _build_file_blocks(context: dict[str, str], agent: Agent) -> str: +def _session_key(agent: Agent) -> str: + if agent.openclaw_session_id: + return agent.openclaw_session_id + return f"agent:{_agent_key(agent)}:main" + + +async def _supported_gateway_files(config: GatewayClientConfig) -> set[str]: + try: + agents_payload = await openclaw_call("agents.list", config=config) + agents = [] + default_id = None + if isinstance(agents_payload, dict): + agents = list(agents_payload.get("agents") or []) + default_id = agents_payload.get("defaultId") or agents_payload.get("default_id") + agent_id = default_id or (agents[0].get("id") if agents else None) + if not agent_id: + return set(DEFAULT_GATEWAY_FILES) + files_payload = await openclaw_call( + "agents.files.list", {"agentId": agent_id}, config=config + ) + if isinstance(files_payload, dict): + files = files_payload.get("files") or [] + supported = {item.get("name") for item in files if isinstance(item, dict)} + return supported or set(DEFAULT_GATEWAY_FILES) + except OpenClawGatewayError: + pass + return set(DEFAULT_GATEWAY_FILES) + + +async def _gateway_agent_files_index( + agent_id: str, config: GatewayClientConfig +) -> dict[str, dict[str, Any]]: + try: + payload = await openclaw_call("agents.files.list", {"agentId": agent_id}, config=config) + if isinstance(payload, dict): + files = payload.get("files") or [] + return { + item.get("name"): item + for item in files + if isinstance(item, dict) and item.get("name") + } + except OpenClawGatewayError: + pass + return {} + + +def _render_agent_files( + context: dict[str, str], + agent: Agent, + file_names: set[str], + *, + include_bootstrap: bool, +) -> dict[str, str]: + env = _template_env() overrides: dict[str, str] = {} if agent.identity_template: overrides["IDENTITY.md"] = agent.identity_template if agent.soul_template: overrides["SOUL.md"] = agent.soul_template - templates = _read_templates(context, overrides=overrides) - return "".join(_render_file_block(name, templates.get(name, "")) for name in TEMPLATE_FILES) + + rendered: dict[str, str] = {} + for name in sorted(file_names): + if name == "BOOTSTRAP.md" and not include_bootstrap: + continue + if name == "MEMORY.md": + rendered[name] = "# MEMORY\n\nBootstrap pending.\n" + continue + override = overrides.get(name) + if override: + rendered[name] = env.from_string(override).render(**context).strip() + continue + path = _templates_root() / name + if path.exists(): + rendered[name] = env.get_template(name).render(**context).strip() + continue + rendered[name] = "" + return rendered -def build_provisioning_message( +async def _patch_gateway_agent_list( + agent_id: str, + workspace_path: str, + heartbeat: dict[str, Any], + config: GatewayClientConfig, +) -> None: + cfg = await openclaw_call("config.get", config=config) + if not isinstance(cfg, dict): + raise OpenClawGatewayError("config.get returned invalid payload") + base_hash = cfg.get("hash") + data = cfg.get("config") or cfg.get("parsed") or {} + if not isinstance(data, dict): + raise OpenClawGatewayError("config.get returned invalid config") + agents = data.get("agents") or {} + lst = agents.get("list") or [] + if not isinstance(lst, list): + raise OpenClawGatewayError("config agents.list is not a list") + + updated = False + new_list: list[dict[str, Any]] = [] + for entry in lst: + if isinstance(entry, dict) and entry.get("id") == agent_id: + new_entry = dict(entry) + new_entry["workspace"] = workspace_path + new_entry["heartbeat"] = heartbeat + new_list.append(new_entry) + updated = True + else: + new_list.append(entry) + if not updated: + new_list.append({"id": agent_id, "workspace": workspace_path, "heartbeat": heartbeat}) + + patch = {"agents": {"list": new_list}} + params = {"raw": json.dumps(patch)} + if base_hash: + params["baseHash"] = base_hash + await openclaw_call("config.patch", params, config=config) + + +async def _remove_gateway_agent_list( + agent_id: str, + config: GatewayClientConfig, +) -> None: + cfg = await openclaw_call("config.get", config=config) + if not isinstance(cfg, dict): + raise OpenClawGatewayError("config.get returned invalid payload") + base_hash = cfg.get("hash") + data = cfg.get("config") or cfg.get("parsed") or {} + if not isinstance(data, dict): + raise OpenClawGatewayError("config.get returned invalid config") + agents = data.get("agents") or {} + lst = agents.get("list") or [] + if not isinstance(lst, list): + raise OpenClawGatewayError("config agents.list is not a list") + + new_list = [entry for entry in lst if not (isinstance(entry, dict) and entry.get("id") == agent_id)] + if len(new_list) == len(lst): + return + patch = {"agents": {"list": new_list}} + params = {"raw": json.dumps(patch)} + if base_hash: + params["baseHash"] = base_hash + await openclaw_call("config.patch", params, config=config) + + +async def provision_agent( agent: Agent, board: Board, gateway: Gateway, auth_token: str, - confirm_token: str, - user: User | None, -) -> str: - context = _build_context(agent, board, gateway, auth_token, user) - file_blocks = _build_file_blocks(context, agent) - 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: {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) Create the workspace directory.\n" - "2) Write the files below with the exact contents.\n" - "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" - " IMPORTANT: Use the configured gateway workspace root. " - "Workspace path must be /workspace-.\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" - "7) After provisioning completes, confirm by calling:\n" - f" POST {context['base_url']}/api/v1/agents/{context['agent_id']}/provision/confirm\n" - f' Body: {{"token": "{confirm_token}", "action": "provision"}}\n\n' - "Files:" + file_blocks - ) - - -def build_update_message( - agent: Agent, - board: Board, - gateway: Gateway, - auth_token: str, - confirm_token: str, - user: User | None, -) -> str: - context = _build_context(agent, board, gateway, auth_token, user) - file_blocks = _build_file_blocks(context, agent) - 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: Use the configured gateway workspace root. " - "Workspace path must be /workspace-.\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" - "7) After the update completes (and only after files are written), confirm by calling:\n" - f" POST {context['base_url']}/api/v1/agents/{context['agent_id']}/provision/confirm\n" - f' Body: {{"token": "{confirm_token}", "action": "update"}}\n' - " Mission Control will send the hello message only after this confirmation.\n\n" - "Files:" + file_blocks - ) - - -async def send_provisioning_message( - agent: Agent, - board: Board, - gateway: Gateway, - auth_token: str, - confirm_token: str, user: User | None, + *, + action: str = "provision", ) -> None: if not gateway.url: return - if not gateway.main_session_key: - raise ValueError("gateway_main_session_key is required") - main_session = gateway.main_session_key + if not gateway.workspace_root: + raise ValueError("gateway_workspace_root is required") client_config = GatewayClientConfig(url=gateway.url, token=gateway.token) - await ensure_session(main_session, config=client_config, label="Main Agent") - message = build_provisioning_message(agent, board, gateway, auth_token, confirm_token, user) - await send_message(message, session_key=main_session, config=client_config, deliver=False) + session_key = _session_key(agent) + await ensure_session(session_key, config=client_config, label=agent.name) + + agent_id = _agent_key(agent) + workspace_path = _workspace_path(agent.name, gateway.workspace_root) + heartbeat = _heartbeat_config(agent) + await _patch_gateway_agent_list(agent_id, workspace_path, heartbeat, client_config) + + context = _build_context(agent, board, gateway, auth_token, user) + supported = await _supported_gateway_files(client_config) + existing_files = await _gateway_agent_files_index(agent_id, client_config) + include_bootstrap = True + if action == "update": + if not existing_files: + include_bootstrap = False + else: + entry = existing_files.get("BOOTSTRAP.md") + if entry and entry.get("missing") is True: + include_bootstrap = False + + rendered = _render_agent_files( + context, + agent, + supported, + include_bootstrap=include_bootstrap, + ) + for name, content in rendered.items(): + if content == "": + continue + await openclaw_call( + "agents.files.set", + {"agentId": agent_id, "name": name, "content": content}, + config=client_config, + ) -async def send_update_message( +async def cleanup_agent_direct( agent: Agent, - board: Board, gateway: Gateway, - auth_token: str, - confirm_token: str, - user: User | None, + *, + delete_workspace: bool = True, ) -> None: if not gateway.url: return - if not gateway.main_session_key: - raise ValueError("gateway_main_session_key is required") - main_session = gateway.main_session_key + if not gateway.workspace_root: + raise ValueError("gateway_workspace_root is required") client_config = GatewayClientConfig(url=gateway.url, token=gateway.token) - await ensure_session(main_session, config=client_config, label="Main Agent") - message = build_update_message(agent, board, gateway, auth_token, confirm_token, user) - await send_message(message, session_key=main_session, config=client_config, deliver=False) + + agent_id = _agent_key(agent) + await _remove_gateway_agent_list(agent_id, client_config) + + session_key = _session_key(agent) + await openclaw_call("sessions.delete", {"key": session_key}, config=client_config) + + if delete_workspace: + workspace_dir = _resolve_workspace_dir(gateway.workspace_root, agent.name) + if workspace_dir.exists(): + shutil.rmtree(workspace_dir) + diff --git a/templates/BOOTSTRAP.md b/templates/BOOTSTRAP.md index f605e214..6d4e6f7c 100644 --- a/templates/BOOTSTRAP.md +++ b/templates/BOOTSTRAP.md @@ -8,9 +8,17 @@ There is no memory yet. Create what is missing and proceed without blocking. 1) Create `memory/` and `memory.md` if missing. 2) Read `IDENTITY.md`, `USER.md`, and `SOUL.md`. 3) If any fields are blank, leave them blank. Do not invent values. -4) Write a short note to `memory.md` that bootstrap completed and list any +4) If `BASE_URL`, `AUTH_TOKEN`, and `BOARD_ID` are set in `TOOLS.md`, check in + to Mission Control to mark the agent online: +```bash +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'", "board_id": "'$BOARD_ID'", "status": "online"}' +``` +5) Write a short note to `memory.md` that bootstrap completed and list any missing fields (e.g., user name, timezone). -5) Delete this file. +6) Delete this file. ## Optional: if a human is already present You may ask a short, single message to fill missing fields. If no reply arrives