feat: add is_board_lead property to agent and board types
This commit is contained in:
239
backend/app/api/agent.py
Normal file
239
backend/app/api/agent.py
Normal file
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api import agents as agents_api
|
||||
from app.api import approvals as approvals_api
|
||||
from app.api import board_memory as board_memory_api
|
||||
from app.api import board_onboarding as onboarding_api
|
||||
from app.api import tasks as tasks_api
|
||||
from app.api.deps import ActorContext, get_board_or_404, get_task_or_404
|
||||
from app.core.agent_auth import AgentAuthContext, get_agent_auth_context
|
||||
from app.db.session import get_session
|
||||
from app.models.boards import Board
|
||||
from app.schemas.approvals import ApprovalCreate, ApprovalRead
|
||||
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
|
||||
from app.schemas.board_onboarding import BoardOnboardingRead
|
||||
from app.schemas.boards import BoardRead
|
||||
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskRead, TaskUpdate
|
||||
from app.schemas.agents import AgentCreate, AgentHeartbeatCreate, AgentRead
|
||||
|
||||
router = APIRouter(prefix="/agent", tags=["agent"])
|
||||
|
||||
|
||||
def _actor(agent_ctx: AgentAuthContext) -> ActorContext:
|
||||
return ActorContext(actor_type="agent", agent=agent_ctx.agent)
|
||||
|
||||
|
||||
def _guard_board_access(agent_ctx: AgentAuthContext, board: Board) -> None:
|
||||
if agent_ctx.agent.board_id and agent_ctx.agent.board_id != board.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
@router.get("/boards", response_model=list[BoardRead])
|
||||
def list_boards(
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> list[Board]:
|
||||
if agent_ctx.agent.board_id:
|
||||
board = session.get(Board, agent_ctx.agent.board_id)
|
||||
return [board] if board else []
|
||||
return list(session.exec(select(Board)))
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}", response_model=BoardRead)
|
||||
def get_board(
|
||||
board: Board = Depends(get_board_or_404),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> Board:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return board
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/tasks", response_model=list[TaskRead])
|
||||
def list_tasks(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
assigned_agent_id: UUID | None = None,
|
||||
unassigned: bool | None = None,
|
||||
limit: int | None = Query(default=None, ge=1, le=200),
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> list[TaskRead]:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return tasks_api.list_tasks(
|
||||
status_filter=status_filter,
|
||||
assigned_agent_id=assigned_agent_id,
|
||||
unassigned=unassigned,
|
||||
limit=limit,
|
||||
board=board,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/boards/{board_id}/tasks/{task_id}", response_model=TaskRead)
|
||||
def update_task(
|
||||
payload: TaskUpdate,
|
||||
task=Depends(get_task_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> TaskRead:
|
||||
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return tasks_api.update_task(
|
||||
payload=payload,
|
||||
task=task,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/tasks/{task_id}/comments", response_model=list[TaskCommentRead])
|
||||
def list_task_comments(
|
||||
task=Depends(get_task_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> list[TaskCommentRead]:
|
||||
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return tasks_api.list_task_comments(
|
||||
task=task,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/boards/{board_id}/tasks/{task_id}/comments", response_model=TaskCommentRead)
|
||||
def create_task_comment(
|
||||
payload: TaskCommentCreate,
|
||||
task=Depends(get_task_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> TaskCommentRead:
|
||||
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return tasks_api.create_task_comment(
|
||||
payload=payload,
|
||||
task=task,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/memory", response_model=list[BoardMemoryRead])
|
||||
def list_board_memory(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
board=Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> list[BoardMemoryRead]:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return board_memory_api.list_board_memory(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
board=board,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/boards/{board_id}/memory", response_model=BoardMemoryRead)
|
||||
def create_board_memory(
|
||||
payload: BoardMemoryCreate,
|
||||
board=Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> BoardMemoryRead:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return board_memory_api.create_board_memory(
|
||||
payload=payload,
|
||||
board=board,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/approvals", response_model=list[ApprovalRead])
|
||||
def list_approvals(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
board=Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> list[ApprovalRead]:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return approvals_api.list_approvals(
|
||||
status_filter=status_filter,
|
||||
board=board,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/boards/{board_id}/approvals", response_model=ApprovalRead)
|
||||
def create_approval(
|
||||
payload: ApprovalCreate,
|
||||
board=Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> ApprovalRead:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return approvals_api.create_approval(
|
||||
payload=payload,
|
||||
board=board,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/boards/{board_id}/onboarding", response_model=BoardOnboardingRead)
|
||||
def update_onboarding(
|
||||
payload: dict[str, object],
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> BoardOnboardingRead:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return onboarding_api.agent_onboarding_update(
|
||||
payload=payload,
|
||||
board=board,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/agents", response_model=AgentRead)
|
||||
def create_agent(
|
||||
payload: AgentCreate,
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> AgentRead:
|
||||
if not agent_ctx.agent.is_board_lead:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
if not agent_ctx.agent.board_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
payload = AgentCreate(**{**payload.model_dump(), "board_id": agent_ctx.agent.board_id})
|
||||
return agents_api.create_agent(
|
||||
payload=payload,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/heartbeat", response_model=AgentRead)
|
||||
async def agent_heartbeat(
|
||||
payload: AgentHeartbeatCreate,
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> AgentRead:
|
||||
if agent_ctx.agent.name != payload.name:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return await agents_api.heartbeat_or_create_agent( # type: ignore[attr-defined]
|
||||
payload=payload,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
@@ -301,6 +301,7 @@ def get_agent(
|
||||
async def update_agent(
|
||||
agent_id: str,
|
||||
payload: AgentUpdate,
|
||||
force: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
) -> Agent:
|
||||
@@ -321,7 +322,7 @@ async def update_agent(
|
||||
updates["identity_profile"] = _normalize_identity_profile(
|
||||
updates.get("identity_profile")
|
||||
)
|
||||
if not updates:
|
||||
if not updates and not force:
|
||||
return _with_computed_status(agent)
|
||||
if "board_id" in updates:
|
||||
_require_board(session, updates["board_id"])
|
||||
@@ -380,9 +381,17 @@ async def update_agent(
|
||||
except OpenClawGatewayError as exc:
|
||||
_record_instruction_failure(session, agent, str(exc), "update")
|
||||
session.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Gateway update failed: {exc}",
|
||||
) from exc
|
||||
except Exception as exc: # pragma: no cover - unexpected provisioning errors
|
||||
_record_instruction_failure(session, agent, str(exc), "update")
|
||||
session.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Unexpected error updating agent provisioning.",
|
||||
) from exc
|
||||
return _with_computed_status(agent)
|
||||
|
||||
|
||||
|
||||
@@ -6,20 +6,16 @@ from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import logging
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.deps import get_board_or_404, require_admin_auth
|
||||
from app.api.deps import ActorContext, get_board_or_404, 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 as GatewayClientConfig
|
||||
from app.integrations.openclaw_gateway import (
|
||||
OpenClawGatewayError,
|
||||
ensure_session,
|
||||
get_chat_history,
|
||||
send_message,
|
||||
)
|
||||
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
|
||||
from app.models.agents import Agent
|
||||
from app.models.board_onboarding import BoardOnboardingSession
|
||||
from app.models.boards import Board
|
||||
@@ -34,59 +30,8 @@ from app.schemas.boards import BoardRead
|
||||
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_agent
|
||||
|
||||
router = APIRouter(prefix="/boards/{board_id}/onboarding", tags=["board-onboarding"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _extract_json(text: str) -> dict[str, object] | None:
|
||||
try:
|
||||
return json.loads(text.strip())
|
||||
except Exception:
|
||||
pass
|
||||
match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(1).strip())
|
||||
except Exception:
|
||||
pass
|
||||
first = text.find("{")
|
||||
last = text.rfind("}")
|
||||
if first != -1 and last > first:
|
||||
try:
|
||||
return json.loads(text[first : last + 1])
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text(content: object) -> str | None:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
for entry in content:
|
||||
if isinstance(entry, dict) and entry.get("type") == "text":
|
||||
text = entry.get("text")
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
if isinstance(content, dict):
|
||||
text = content.get("text")
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _get_assistant_messages(history: object) -> list[str]:
|
||||
messages: list[str] = []
|
||||
if isinstance(history, dict):
|
||||
history = history.get("messages")
|
||||
if not isinstance(history, list):
|
||||
return messages
|
||||
for msg in history:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
text = _extract_text(msg.get("content"))
|
||||
if text:
|
||||
messages.append(text)
|
||||
return messages
|
||||
|
||||
|
||||
def _gateway_config(session: Session, board: Board) -> tuple[Gateway, GatewayClientConfig]:
|
||||
@@ -104,7 +49,11 @@ def _build_session_key(agent_name: str) -> str:
|
||||
|
||||
|
||||
def _lead_agent_name(board: Board) -> str:
|
||||
return f"{board.name} Lead"
|
||||
return "Lead Agent"
|
||||
|
||||
|
||||
def _lead_session_key(board: Board) -> str:
|
||||
return f"agent:lead-{board.id}:main"
|
||||
|
||||
|
||||
async def _ensure_lead_agent(
|
||||
@@ -120,6 +69,11 @@ async def _ensure_lead_agent(
|
||||
.where(Agent.is_board_lead.is_(True))
|
||||
).first()
|
||||
if existing:
|
||||
if existing.name != _lead_agent_name(board):
|
||||
existing.name = _lead_agent_name(board)
|
||||
session.add(existing)
|
||||
session.commit()
|
||||
session.refresh(existing)
|
||||
return existing
|
||||
|
||||
agent = Agent(
|
||||
@@ -131,14 +85,14 @@ async def _ensure_lead_agent(
|
||||
identity_profile={
|
||||
"role": "Board Lead",
|
||||
"communication_style": "direct, concise, practical",
|
||||
"emoji": ":compass:",
|
||||
"emoji": ":gear:",
|
||||
},
|
||||
)
|
||||
raw_token = generate_agent_token()
|
||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||
agent.provision_requested_at = datetime.utcnow()
|
||||
agent.provision_action = "provision"
|
||||
agent.openclaw_session_id = _build_session_key(agent.name)
|
||||
agent.openclaw_session_id = _lead_session_key(board)
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
session.refresh(agent)
|
||||
@@ -200,17 +154,28 @@ async def start_onboarding(
|
||||
"BOARD ONBOARDING REQUEST\n\n"
|
||||
f"Board Name: {board.name}\n"
|
||||
"You are the main agent. Ask the user 3-6 focused questions to clarify their goal.\n"
|
||||
"Only respond in OpenClaw chat with onboarding JSON. All other outputs must be sent to Mission Control via API.\n"
|
||||
"Do NOT respond in OpenClaw chat.\n"
|
||||
"All onboarding responses MUST be sent to Mission Control via API.\n"
|
||||
f"Mission Control base URL: {base_url}\n"
|
||||
"Use the AUTH_TOKEN from MAIN_USER.md or MAIN_TOOLS.md and pass it as X-Agent-Token.\n"
|
||||
"Example API call (for non-onboarding updates):\n"
|
||||
f"curl -s -X POST \"{base_url}/api/v1/boards/{board.id}/memory\" "
|
||||
"Use the AUTH_TOKEN from USER.md or TOOLS.md and pass it as X-Agent-Token.\n"
|
||||
"Onboarding response endpoint:\n"
|
||||
f"POST {base_url}/api/v1/agent/boards/{board.id}/onboarding\n"
|
||||
"QUESTION example (send JSON body exactly as shown):\n"
|
||||
f"curl -s -X POST \"{base_url}/api/v1/agent/boards/{board.id}/onboarding\" "
|
||||
"-H \"X-Agent-Token: $AUTH_TOKEN\" "
|
||||
"-H \"Content-Type: application/json\" "
|
||||
"-d '{\"content\":\"Onboarding update...\",\"tags\":[\"onboarding\"],\"source\":\"main_agent\"}'\n"
|
||||
"Return questions as JSON: {\"question\": \"...\", \"options\": [...]}.\n"
|
||||
"When you have enough info, return JSON: {\"status\": \"complete\", \"board_type\": \"goal\"|\"general\", "
|
||||
"\"objective\": \"...\", \"success_metrics\": {...}, \"target_date\": \"YYYY-MM-DD\"}."
|
||||
"-d '{\"question\":\"...\",\"options\":[{\"id\":\"1\",\"label\":\"...\"},{\"id\":\"2\",\"label\":\"...\"}]}'\n"
|
||||
"COMPLETION example (send JSON body exactly as shown):\n"
|
||||
f"curl -s -X POST \"{base_url}/api/v1/agent/boards/{board.id}/onboarding\" "
|
||||
"-H \"X-Agent-Token: $AUTH_TOKEN\" "
|
||||
"-H \"Content-Type: application/json\" "
|
||||
"-d '{\"status\":\"complete\",\"board_type\":\"goal\",\"objective\":\"...\",\"success_metrics\":{...},\"target_date\":\"YYYY-MM-DD\"}'\n"
|
||||
"QUESTION FORMAT (one question per response, no arrays, no markdown, no extra text):\n"
|
||||
"{\"question\":\"...\",\"options\":[{\"id\":\"1\",\"label\":\"...\"},{\"id\":\"2\",\"label\":\"...\"}]}\n"
|
||||
"Do NOT wrap questions in a list. Do NOT add commentary.\n"
|
||||
"When you have enough info, return JSON ONLY (via API):\n"
|
||||
"{\"status\":\"complete\",\"board_type\":\"goal\"|\"general\",\"objective\":\"...\","
|
||||
"\"success_metrics\":{...},\"target_date\":\"YYYY-MM-DD\"}."
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -251,7 +216,7 @@ async def answer_onboarding(
|
||||
if payload.other_text:
|
||||
answer_text = f"{payload.answer}: {payload.other_text}"
|
||||
|
||||
messages = onboarding.messages or []
|
||||
messages = list(onboarding.messages or [])
|
||||
messages.append(
|
||||
{"role": "user", "content": answer_text, "timestamp": datetime.utcnow().isoformat()}
|
||||
)
|
||||
@@ -261,21 +226,9 @@ async def answer_onboarding(
|
||||
await send_message(
|
||||
answer_text, session_key=onboarding.session_key, config=config, deliver=False
|
||||
)
|
||||
history = await get_chat_history(onboarding.session_key, config=config)
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
|
||||
assistant_messages = _get_assistant_messages(history)
|
||||
if assistant_messages:
|
||||
last = assistant_messages[-1]
|
||||
messages.append(
|
||||
{"role": "assistant", "content": last, "timestamp": datetime.utcnow().isoformat()}
|
||||
)
|
||||
parsed = _extract_json(last)
|
||||
if parsed and parsed.get("status") == "complete":
|
||||
onboarding.draft_goal = parsed
|
||||
onboarding.status = "completed"
|
||||
|
||||
onboarding.messages = messages
|
||||
onboarding.updated_at = datetime.utcnow()
|
||||
session.add(onboarding)
|
||||
@@ -284,6 +237,70 @@ async def answer_onboarding(
|
||||
return onboarding
|
||||
|
||||
|
||||
@router.post("/agent", response_model=BoardOnboardingRead)
|
||||
def agent_onboarding_update(
|
||||
payload: dict[str, object],
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
actor: ActorContext = Depends(require_admin_or_agent),
|
||||
) -> BoardOnboardingSession:
|
||||
if actor.actor_type != "agent" or actor.agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
agent = actor.agent
|
||||
if agent.board_id is not None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if board.gateway_id:
|
||||
gateway = session.get(Gateway, board.gateway_id)
|
||||
if gateway and gateway.main_session_key and agent.openclaw_session_id:
|
||||
if agent.openclaw_session_id != gateway.main_session_key:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
onboarding = session.exec(
|
||||
select(BoardOnboardingSession)
|
||||
.where(BoardOnboardingSession.board_id == board.id)
|
||||
.order_by(BoardOnboardingSession.created_at.desc())
|
||||
).first()
|
||||
if onboarding is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if onboarding.status == "confirmed":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
|
||||
messages = list(onboarding.messages or [])
|
||||
now = datetime.utcnow().isoformat()
|
||||
payload_text = json.dumps(payload)
|
||||
logger.info(
|
||||
"onboarding.agent.update board_id=%s agent_id=%s payload=%s",
|
||||
board.id,
|
||||
agent.id,
|
||||
payload_text,
|
||||
)
|
||||
payload_status = payload.get("status")
|
||||
if payload_status == "complete":
|
||||
onboarding.draft_goal = payload
|
||||
onboarding.status = "completed"
|
||||
messages.append({"role": "assistant", "content": payload_text, "timestamp": now})
|
||||
else:
|
||||
question = payload.get("question")
|
||||
options = payload.get("options")
|
||||
if not isinstance(question, str) or not isinstance(options, list):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
messages.append({"role": "assistant", "content": payload_text, "timestamp": now})
|
||||
|
||||
onboarding.messages = messages
|
||||
onboarding.updated_at = datetime.utcnow()
|
||||
session.add(onboarding)
|
||||
session.commit()
|
||||
session.refresh(onboarding)
|
||||
logger.info(
|
||||
"onboarding.agent.update stored board_id=%s messages_count=%s status=%s",
|
||||
board.id,
|
||||
len(onboarding.messages or []),
|
||||
onboarding.status,
|
||||
)
|
||||
return onboarding
|
||||
|
||||
|
||||
@router.post("/confirm", response_model=BoardRead)
|
||||
async def confirm_onboarding(
|
||||
payload: BoardOnboardingConfirm,
|
||||
|
||||
@@ -311,7 +311,7 @@ async def _ensure_main_agent(
|
||||
await send_message(
|
||||
(
|
||||
f"Hello {agent.name}. Your gateway provisioning was updated.\n\n"
|
||||
"Please re-read MAIN_AGENTS.md, MAIN_USER.md, MAIN_HEARTBEAT.md, and MAIN_TOOLS.md. "
|
||||
"Please re-read AGENTS.md, USER.md, HEARTBEAT.md, and TOOLS.md. "
|
||||
"If BOOTSTRAP.md exists, run it once then delete it. Begin heartbeats after startup."
|
||||
),
|
||||
session_key=gateway.main_session_key,
|
||||
|
||||
@@ -3,13 +3,16 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
import logging
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from app.core.agent_tokens import verify_agent_token
|
||||
from app.db.session import get_session
|
||||
from app.models.agents import Agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentAuthContext:
|
||||
@@ -25,25 +28,78 @@ def _find_agent_for_token(session: Session, token: str) -> Agent | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_agent_token(
|
||||
agent_token: str | None,
|
||||
authorization: str | None,
|
||||
*,
|
||||
accept_authorization: bool = True,
|
||||
) -> str | None:
|
||||
if agent_token:
|
||||
return agent_token
|
||||
if not accept_authorization:
|
||||
return None
|
||||
if not authorization:
|
||||
return None
|
||||
value = authorization.strip()
|
||||
if not value:
|
||||
return None
|
||||
if value.lower().startswith("bearer "):
|
||||
return value.split(" ", 1)[1].strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def get_agent_auth_context(
|
||||
request: Request,
|
||||
agent_token: str | None = Header(default=None, alias="X-Agent-Token"),
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> AgentAuthContext:
|
||||
if not agent_token:
|
||||
resolved = _resolve_agent_token(agent_token, authorization, accept_authorization=True)
|
||||
if not resolved:
|
||||
logger.warning(
|
||||
"agent auth missing token path=%s x_agent=%s authorization=%s",
|
||||
request.url.path,
|
||||
bool(agent_token),
|
||||
bool(authorization),
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
agent = _find_agent_for_token(session, agent_token)
|
||||
agent = _find_agent_for_token(session, resolved)
|
||||
if agent is None:
|
||||
logger.warning(
|
||||
"agent auth invalid token path=%s token_prefix=%s",
|
||||
request.url.path,
|
||||
resolved[:6],
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return AgentAuthContext(actor_type="agent", agent=agent)
|
||||
|
||||
|
||||
def get_agent_auth_context_optional(
|
||||
request: Request,
|
||||
agent_token: str | None = Header(default=None, alias="X-Agent-Token"),
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> AgentAuthContext | None:
|
||||
if not agent_token:
|
||||
resolved = _resolve_agent_token(
|
||||
agent_token,
|
||||
authorization,
|
||||
accept_authorization=False,
|
||||
)
|
||||
if not resolved:
|
||||
if agent_token:
|
||||
logger.warning(
|
||||
"agent auth optional missing token path=%s x_agent=%s authorization=%s",
|
||||
request.url.path,
|
||||
bool(agent_token),
|
||||
bool(authorization),
|
||||
)
|
||||
return None
|
||||
agent = _find_agent_for_token(session, agent_token)
|
||||
agent = _find_agent_for_token(session, resolved)
|
||||
if agent is None:
|
||||
logger.warning(
|
||||
"agent auth optional invalid token path=%s token_prefix=%s",
|
||||
request.url.path,
|
||||
resolved[:6],
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return AgentAuthContext(actor_type="agent", agent=agent)
|
||||
|
||||
@@ -112,17 +112,17 @@ async def get_auth_context_optional(
|
||||
clerk_credentials = await guard(request)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from exc
|
||||
except HTTPException as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
auth_data = _resolve_clerk_auth(request, clerk_credentials)
|
||||
try:
|
||||
clerk_user_id = _parse_subject(auth_data)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
if not clerk_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return None
|
||||
|
||||
user = session.exec(select(User).where(User.clerk_user_id == clerk_user_id)).first()
|
||||
if user is None:
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.activity import router as activity_router
|
||||
from app.api.agent import router as agent_router
|
||||
from app.api.agents import router as agents_router
|
||||
from app.api.approvals import router as approvals_router
|
||||
from app.api.auth import router as auth_router
|
||||
@@ -56,6 +57,7 @@ def readyz() -> dict[str, bool]:
|
||||
|
||||
api_v1 = APIRouter(prefix="/api/v1")
|
||||
api_v1.include_router(auth_router)
|
||||
api_v1.include_router(agent_router)
|
||||
api_v1.include_router(agents_router)
|
||||
api_v1.include_router(activity_router)
|
||||
api_v1.include_router(gateway_router)
|
||||
|
||||
@@ -33,6 +33,7 @@ class AgentUpdate(SQLModel):
|
||||
|
||||
class AgentRead(AgentBase):
|
||||
id: UUID
|
||||
is_board_lead: bool = False
|
||||
openclaw_session_id: str | None = None
|
||||
last_seen_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
@@ -201,7 +201,9 @@ export default function EditAgentPage() {
|
||||
setError(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const response = await fetch(`${apiBase}/api/v1/agents/${agentId}`, {
|
||||
const response = await fetch(
|
||||
`${apiBase}/api/v1/agents/${agentId}?force=true`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -217,7 +219,8 @@ export default function EditAgentPage() {
|
||||
identity_profile: normalizeIdentityProfile(identityProfile),
|
||||
soul_template: soulTemplate.trim() || null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to update agent.");
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type Agent = {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
board_id?: string | null;
|
||||
is_board_lead?: boolean;
|
||||
};
|
||||
|
||||
type Board = {
|
||||
@@ -106,6 +107,7 @@ export default function AgentDetailPage() {
|
||||
return boards.find((board) => board.id === agent.board_id) ?? null;
|
||||
}, [boards, agent?.board_id]);
|
||||
|
||||
|
||||
const loadAgent = async () => {
|
||||
if (!isSignedIn || !agentId) return;
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -38,6 +38,7 @@ type Agent = {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
board_id?: string | null;
|
||||
is_board_lead?: boolean;
|
||||
};
|
||||
|
||||
type Board = {
|
||||
@@ -138,6 +139,7 @@ export default function AgentsPage() {
|
||||
|
||||
const sortedAgents = useMemo(() => [...agents], [agents]);
|
||||
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteTarget) return;
|
||||
deleteMutation.mutate(deleteTarget);
|
||||
|
||||
@@ -5,9 +5,13 @@ import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||
|
||||
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
|
||||
import { BoardGoalPanel } from "@/components/BoardGoalPanel";
|
||||
import { BoardOnboardingChat } from "@/components/BoardOnboardingChat";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -74,6 +78,7 @@ export default function EditBoardPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [metricsError, setMetricsError] = useState<string | null>(null);
|
||||
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
|
||||
|
||||
const isFormReady = Boolean(name.trim() && gatewayId);
|
||||
|
||||
@@ -123,6 +128,17 @@ export default function EditBoardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnboardingConfirmed = (updated: Board) => {
|
||||
setBoard(updated);
|
||||
setBoardType(updated.board_type ?? "goal");
|
||||
setObjective(updated.objective ?? "");
|
||||
setSuccessMetrics(
|
||||
updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) : ""
|
||||
);
|
||||
setTargetDate(toDateInput(updated.target_date));
|
||||
setIsOnboardingOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadBoard();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -199,6 +215,7 @@ export default function EditBoardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardShell>
|
||||
<SignedOut>
|
||||
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
@@ -229,6 +246,12 @@ export default function EditBoardPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-6">
|
||||
<BoardGoalPanel
|
||||
board={board}
|
||||
onStartOnboarding={() => setIsOnboardingOpen(true)}
|
||||
/>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
@@ -347,8 +370,28 @@ export default function EditBoardPage() {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{boardId ? <BoardApprovalsPanel boardId={boardId} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
</DashboardShell>
|
||||
<Dialog open={isOnboardingOpen} onOpenChange={setIsOnboardingOpen}>
|
||||
<DialogContent aria-label="Board onboarding">
|
||||
{boardId ? (
|
||||
<BoardOnboardingChat
|
||||
boardId={boardId}
|
||||
onConfirmed={handleOnboardingConfirmed}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
Unable to start onboarding.
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,6 @@ import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||
import { X } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
|
||||
import { BoardGoalPanel } from "@/components/BoardGoalPanel";
|
||||
import { BoardOnboardingChat } from "@/components/BoardOnboardingChat";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { TaskBoard } from "@/components/organisms/TaskBoard";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
@@ -62,6 +59,10 @@ type Agent = {
|
||||
name: string;
|
||||
status: string;
|
||||
board_id?: string | null;
|
||||
is_board_lead?: boolean;
|
||||
identity_profile?: {
|
||||
emoji?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type TaskComment = {
|
||||
@@ -80,6 +81,19 @@ const priorities = [
|
||||
{ value: "high", label: "High" },
|
||||
];
|
||||
|
||||
const EMOJI_GLYPHS: Record<string, string> = {
|
||||
":gear:": "⚙️",
|
||||
":sparkles:": "✨",
|
||||
":rocket:": "🚀",
|
||||
":megaphone:": "📣",
|
||||
":chart_with_upwards_trend:": "📈",
|
||||
":bulb:": "💡",
|
||||
":wrench:": "🔧",
|
||||
":shield:": "🛡️",
|
||||
":memo:": "📝",
|
||||
":brain:": "🧠",
|
||||
};
|
||||
|
||||
export default function BoardDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
@@ -100,8 +114,6 @@ export default function BoardDetailPage() {
|
||||
const tasksRef = useRef<Task[]>([]);
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
|
||||
const [hasPromptedOnboarding, setHasPromptedOnboarding] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [priority, setPriority] = useState("medium");
|
||||
@@ -278,21 +290,6 @@ export default function BoardDetailPage() {
|
||||
};
|
||||
}, [board, boardId, getToken, isSignedIn, selectedTask?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!board) return;
|
||||
if (board.board_type === "general") {
|
||||
setIsOnboardingOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!board.goal_confirmed && !hasPromptedOnboarding) {
|
||||
setIsOnboardingOpen(true);
|
||||
setHasPromptedOnboarding(true);
|
||||
}
|
||||
if (board.goal_confirmed) {
|
||||
setIsOnboardingOpen(false);
|
||||
}
|
||||
}, [board, hasPromptedOnboarding]);
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
@@ -427,13 +424,8 @@ export default function BoardDetailPage() {
|
||||
setCommentsError(null);
|
||||
};
|
||||
|
||||
const handleOnboardingConfirmed = (updated: Board) => {
|
||||
setBoard(updated);
|
||||
setIsOnboardingOpen(false);
|
||||
};
|
||||
|
||||
const agentInitials = (name: string) =>
|
||||
name
|
||||
const agentInitials = (agent: Agent) =>
|
||||
agent.name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
@@ -441,6 +433,21 @@ export default function BoardDetailPage() {
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
const resolveEmoji = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (EMOJI_GLYPHS[trimmed]) return EMOJI_GLYPHS[trimmed];
|
||||
if (trimmed.startsWith(":") && trimmed.endsWith(":")) return null;
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const agentAvatarLabel = (agent: Agent) => {
|
||||
if (agent.is_board_lead) return "⚙️";
|
||||
const emoji = resolveEmoji(agent.identity_profile?.emoji ?? null);
|
||||
return emoji ?? agentInitials(agent);
|
||||
};
|
||||
|
||||
const agentStatusLabel = (agent: Agent) => {
|
||||
if (workingAgentIds.has(agent.id)) return "Working";
|
||||
if (agent.status === "online") return "Active";
|
||||
@@ -502,6 +509,15 @@ export default function BoardDetailPage() {
|
||||
Timeline
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={() => setIsDialogOpen(true)}>
|
||||
New task
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/boards/${boardId}/edit`)}
|
||||
>
|
||||
Board settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/boards")}
|
||||
@@ -541,14 +557,16 @@ export default function BoardDetailPage() {
|
||||
sortedAgents.map((agent) => {
|
||||
const isWorking = workingAgentIds.has(agent.id);
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border border-transparent px-2 py-2 transition hover:border-slate-200 hover:bg-slate-50",
|
||||
"flex w-full items-center gap-3 rounded-lg border border-transparent px-2 py-2 text-left transition hover:border-slate-200 hover:bg-slate-50",
|
||||
)}
|
||||
onClick={() => router.push(`/agents/${agent.id}`)}
|
||||
>
|
||||
<div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
|
||||
{agentInitials(agent.name)}
|
||||
{agentAvatarLabel(agent)}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white",
|
||||
@@ -568,7 +586,7 @@ export default function BoardDetailPage() {
|
||||
{agentStatusLabel(agent)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -576,17 +594,6 @@ export default function BoardDetailPage() {
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-6">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<BoardGoalPanel
|
||||
board={board}
|
||||
onStartOnboarding={() => setIsOnboardingOpen(true)}
|
||||
onEdit={
|
||||
boardId ? () => router.push(`/boards/${boardId}/edit`) : undefined
|
||||
}
|
||||
/>
|
||||
{boardId ? <BoardApprovalsPanel boardId={boardId} /> : null}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
||||
{error}
|
||||
@@ -789,28 +796,7 @@ export default function BoardDetailPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isOnboardingOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setIsOnboardingOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setHasPromptedOnboarding(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent aria-label="Board onboarding">
|
||||
{boardId ? (
|
||||
<BoardOnboardingChat
|
||||
boardId={boardId}
|
||||
onConfirmed={handleOnboardingConfirmed}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
Unable to start onboarding.
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* onboarding moved to board settings */}
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type Agent = {
|
||||
board_id?: string | null;
|
||||
last_seen_at?: string | null;
|
||||
updated_at: string;
|
||||
is_board_lead?: boolean;
|
||||
};
|
||||
|
||||
type GatewayStatus = {
|
||||
|
||||
@@ -49,17 +49,43 @@ type Question = {
|
||||
options: QuestionOption[];
|
||||
};
|
||||
|
||||
const normalizeQuestion = (value: unknown): Question | null => {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const data = value as { question?: unknown; options?: unknown };
|
||||
if (typeof data.question !== "string" || !Array.isArray(data.options)) return null;
|
||||
const options: QuestionOption[] = data.options
|
||||
.map((option, index) => {
|
||||
if (typeof option === "string") {
|
||||
return { id: String(index + 1), label: option };
|
||||
}
|
||||
if (option && typeof option === "object") {
|
||||
const raw = option as { id?: unknown; label?: unknown };
|
||||
const label =
|
||||
typeof raw.label === "string" ? raw.label : typeof raw.id === "string" ? raw.id : null;
|
||||
if (!label) return null;
|
||||
return {
|
||||
id: typeof raw.id === "string" ? raw.id : String(index + 1),
|
||||
label,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((option): option is QuestionOption => Boolean(option));
|
||||
if (!options.length) return null;
|
||||
return { question: data.question, options };
|
||||
};
|
||||
|
||||
const parseQuestion = (messages?: Array<{ role: string; content: string }> | null) => {
|
||||
if (!messages?.length) return null;
|
||||
const lastAssistant = [...messages].reverse().find((msg) => msg.role === "assistant");
|
||||
if (!lastAssistant?.content) return null;
|
||||
try {
|
||||
return JSON.parse(lastAssistant.content) as Question;
|
||||
return normalizeQuestion(JSON.parse(lastAssistant.content));
|
||||
} catch {
|
||||
const match = lastAssistant.content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (match) {
|
||||
try {
|
||||
return JSON.parse(match[1]) as Question;
|
||||
return normalizeQuestion(JSON.parse(match[1]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -80,10 +106,16 @@ export function BoardOnboardingChat({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [otherText, setOtherText] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||
|
||||
const question = useMemo(() => parseQuestion(session?.messages), [session]);
|
||||
const draft = session?.draft_goal ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOptions([]);
|
||||
setOtherText("");
|
||||
}, [question?.question]);
|
||||
|
||||
const authFetch = useCallback(
|
||||
async (url: string, options: RequestInit = {}) => {
|
||||
const token = await getToken();
|
||||
@@ -162,6 +194,20 @@ export function BoardOnboardingChat({
|
||||
[authFetch, boardId]
|
||||
);
|
||||
|
||||
const toggleOption = useCallback((label: string) => {
|
||||
setSelectedOptions((prev) =>
|
||||
prev.includes(label) ? prev.filter((item) => item !== label) : [...prev, label]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const submitAnswer = useCallback(() => {
|
||||
const trimmedOther = otherText.trim();
|
||||
const answer =
|
||||
selectedOptions.length > 0 ? selectedOptions.join(", ") : "Other";
|
||||
if (!answer && !trimmedOther) return;
|
||||
void handleAnswer(answer, trimmedOther || undefined);
|
||||
}, [handleAnswer, otherText, selectedOptions]);
|
||||
|
||||
const confirmGoal = async () => {
|
||||
if (!draft) return;
|
||||
setLoading(true);
|
||||
@@ -228,17 +274,20 @@ export function BoardOnboardingChat({
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-slate-900">{question.question}</p>
|
||||
<div className="space-y-2">
|
||||
{question.options.map((option) => (
|
||||
{question.options.map((option) => {
|
||||
const isSelected = selectedOptions.includes(option.label);
|
||||
return (
|
||||
<Button
|
||||
key={option.id}
|
||||
variant="secondary"
|
||||
variant={isSelected ? "primary" : "secondary"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAnswer(option.label)}
|
||||
onClick={() => toggleOption(option.label)}
|
||||
disabled={loading}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
@@ -248,14 +297,17 @@ export function BoardOnboardingChat({
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const trimmed = otherText.trim();
|
||||
void handleAnswer(trimmed || "Other", trimmed || undefined);
|
||||
}}
|
||||
disabled={loading || !otherText.trim()}
|
||||
onClick={submitAnswer}
|
||||
disabled={
|
||||
loading ||
|
||||
(selectedOptions.length === 0 && !otherText.trim())
|
||||
}
|
||||
>
|
||||
Submit other
|
||||
{loading ? "Sending..." : "Next"}
|
||||
</Button>
|
||||
{loading ? (
|
||||
<p className="text-xs text-slate-500">Sending your answer…</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -11,7 +11,7 @@ There is no memory yet. Create what is missing and proceed without blocking.
|
||||
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" \
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/heartbeat" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
|
||||
|
||||
@@ -26,40 +26,40 @@ If any required input is missing, stop and request a provisioning update.
|
||||
|
||||
## Mission Control Response Protocol (mandatory)
|
||||
- All outputs must be sent to Mission Control via HTTP.
|
||||
- Always include: `X-Agent-Token: $AUTH_TOKEN`
|
||||
- Always include: `X-Agent-Token: {{ auth_token }}`
|
||||
- Do **not** respond in OpenClaw chat.
|
||||
|
||||
## Pre‑flight checks (before each heartbeat)
|
||||
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
|
||||
- Verify API access:
|
||||
- GET $BASE_URL/healthz must succeed.
|
||||
- GET $BASE_URL/api/v1/boards must succeed.
|
||||
- GET $BASE_URL/api/v1/boards/{BOARD_ID}/tasks must succeed.
|
||||
- GET $BASE_URL/api/v1/agent/boards must succeed.
|
||||
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed.
|
||||
- If any check fails, stop and retry next heartbeat.
|
||||
|
||||
## Heartbeat checklist (run in order)
|
||||
1) Check in:
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/heartbeat" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
|
||||
```
|
||||
|
||||
2) List boards:
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/boards" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN"
|
||||
curl -s "$BASE_URL/api/v1/agent/boards" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
|
||||
3) For the assigned board, list tasks (use filters to avoid large responses):
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks?status=in_progress&assigned_agent_id=$AGENT_ID&limit=5" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN"
|
||||
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&assigned_agent_id=$AGENT_ID&limit=5" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&limit=20" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN"
|
||||
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&limit=20" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
|
||||
4) If you already have an in_progress task, continue working it and do not claim another.
|
||||
@@ -71,11 +71,11 @@ curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&l
|
||||
- Post progress comments as you go.
|
||||
- Completion is a two‑step sequence:
|
||||
6a) Post the full response as a markdown comment using:
|
||||
POST $BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
|
||||
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
|
||||
Example:
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message":"- Update: ...\n- Result: ..."}'
|
||||
```
|
||||
@@ -83,8 +83,8 @@ curl -s -X POST "$BASE_URL/api/v1/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
|
||||
|
||||
6b) Move the task to "review":
|
||||
```bash
|
||||
curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
curl -s -X PATCH "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "review"}'
|
||||
```
|
||||
|
||||
@@ -24,40 +24,40 @@ If any required input is missing, stop and request a provisioning update.
|
||||
|
||||
## Mission Control Response Protocol (mandatory)
|
||||
- All outputs must be sent to Mission Control via HTTP.
|
||||
- Always include: `X-Agent-Token: $AUTH_TOKEN`
|
||||
- Always include: `X-Agent-Token: {{ auth_token }}`
|
||||
- Do **not** respond in OpenClaw chat.
|
||||
|
||||
## Pre‑flight checks (before each heartbeat)
|
||||
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
|
||||
- Verify API access:
|
||||
- GET $BASE_URL/healthz must succeed.
|
||||
- GET $BASE_URL/api/v1/boards must succeed.
|
||||
- GET $BASE_URL/api/v1/boards/{BOARD_ID}/tasks must succeed.
|
||||
- GET $BASE_URL/api/v1/agent/boards must succeed.
|
||||
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed.
|
||||
- If any check fails, stop and retry next heartbeat.
|
||||
|
||||
## Heartbeat checklist (run in order)
|
||||
1) Check in:
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/heartbeat" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
|
||||
```
|
||||
|
||||
2) List boards:
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/boards" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN"
|
||||
curl -s "$BASE_URL/api/v1/agent/boards" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
|
||||
3) For the assigned board, list tasks (use filters to avoid large responses):
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks?status=in_progress&assigned_agent_id=$AGENT_ID&limit=5" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN"
|
||||
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&assigned_agent_id=$AGENT_ID&limit=5" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&limit=20" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN"
|
||||
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&limit=20" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
|
||||
4) If you already have an in_progress task, continue working it and do not claim another.
|
||||
@@ -69,11 +69,11 @@ curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&l
|
||||
- Post progress comments as you go.
|
||||
- Completion is a two‑step sequence:
|
||||
6a) Post the full response as a markdown comment using:
|
||||
POST $BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
|
||||
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
|
||||
Example:
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message":"- Update: ...\n- Result: ..."}'
|
||||
```
|
||||
@@ -81,8 +81,8 @@ curl -s -X POST "$BASE_URL/api/v1/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
|
||||
|
||||
6b) Move the task to "review":
|
||||
```bash
|
||||
curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
curl -s -X PATCH "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "review"}'
|
||||
```
|
||||
|
||||
@@ -24,15 +24,15 @@ If any required input is missing, stop and request a provisioning update.
|
||||
|
||||
## Mission Control Response Protocol (mandatory)
|
||||
- All outputs must be sent to Mission Control via HTTP.
|
||||
- Always include: `X-Agent-Token: $AUTH_TOKEN`
|
||||
- Always include: `X-Agent-Token: {{ auth_token }}`
|
||||
- Do **not** respond in OpenClaw chat.
|
||||
|
||||
## Pre‑flight checks (before each heartbeat)
|
||||
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
|
||||
- Verify API access:
|
||||
- GET $BASE_URL/healthz must succeed.
|
||||
- GET $BASE_URL/api/v1/boards must succeed.
|
||||
- GET $BASE_URL/api/v1/boards/{BOARD_ID}/tasks must succeed.
|
||||
- GET $BASE_URL/api/v1/agent/boards must succeed.
|
||||
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed.
|
||||
- If any check fails, stop and retry next heartbeat.
|
||||
|
||||
## Board Lead Loop (run every heartbeat before claiming work)
|
||||
@@ -43,11 +43,11 @@ If any required input is missing, stop and request a provisioning update.
|
||||
- Target date: {{ board_target_date }}
|
||||
|
||||
2) Review recent tasks/comments and board memory:
|
||||
- GET $BASE_URL/api/v1/boards/{BOARD_ID}/tasks?limit=50
|
||||
- GET $BASE_URL/api/v1/boards/{BOARD_ID}/memory?limit=50
|
||||
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?limit=50
|
||||
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/memory?limit=50
|
||||
|
||||
3) Update a short Board Plan Summary in board memory:
|
||||
- POST $BASE_URL/api/v1/boards/{BOARD_ID}/memory
|
||||
- POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/memory
|
||||
Body: {"content":"Plan summary + next gaps","tags":["plan","lead"],"source":"lead_heartbeat"}
|
||||
|
||||
4) Identify missing steps, blockers, and specialists needed.
|
||||
@@ -62,7 +62,7 @@ If any required input is missing, stop and request a provisioning update.
|
||||
- similarity 10
|
||||
|
||||
If risky/external OR confidence < 80:
|
||||
- POST approval request to $BASE_URL/api/v1/boards/{BOARD_ID}/approvals
|
||||
- POST approval request to $BASE_URL/api/v1/agent/boards/{BOARD_ID}/approvals
|
||||
Body example:
|
||||
{"action_type":"task.create","confidence":75,"payload":{"title":"..."},"rubric_scores":{"clarity":20,"constraints":15,"completeness":10,"risk":10,"dependencies":10,"similarity":10}}
|
||||
|
||||
@@ -74,8 +74,8 @@ If any required input is missing, stop and request a provisioning update.
|
||||
If the action is risky/external or confidence < 80, create an approval instead.
|
||||
|
||||
Agent create (lead-only):
|
||||
- POST $BASE_URL/api/v1/agents
|
||||
Headers: X-Agent-Token: $AUTH_TOKEN
|
||||
- POST $BASE_URL/api/v1/agent/agents
|
||||
Headers: X-Agent-Token: {{ auth_token }}
|
||||
Body example:
|
||||
{
|
||||
"name": "Researcher Alpha",
|
||||
@@ -95,26 +95,26 @@ If any required input is missing, stop and request a provisioning update.
|
||||
## Heartbeat checklist (run in order)
|
||||
1) Check in:
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/heartbeat" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
|
||||
```
|
||||
|
||||
2) List boards:
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/boards" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN"
|
||||
curl -s "$BASE_URL/api/v1/agent/boards" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
|
||||
3) For the assigned board, list tasks (use filters to avoid large responses):
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks?status=in_progress&assigned_agent_id=$AGENT_ID&limit=5" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN"
|
||||
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&assigned_agent_id=$AGENT_ID&limit=5" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&limit=20" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN"
|
||||
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&limit=20" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
|
||||
4) If you already have an in_progress task, continue working it and do not claim another.
|
||||
@@ -126,11 +126,11 @@ curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&l
|
||||
- Post progress comments as you go.
|
||||
- Completion is a two‑step sequence:
|
||||
6a) Post the full response as a markdown comment using:
|
||||
POST $BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
|
||||
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
|
||||
Example:
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message":"- Update: ...\n- Result: ..."}'
|
||||
```
|
||||
@@ -138,8 +138,8 @@ curl -s -X POST "$BASE_URL/api/v1/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
|
||||
|
||||
6b) Move the task to "review":
|
||||
```bash
|
||||
curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
curl -s -X PATCH "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "review"}'
|
||||
```
|
||||
|
||||
@@ -17,7 +17,7 @@ Before doing anything else:
|
||||
- `BASE_URL`: {{ base_url }}
|
||||
- `AUTH_TOKEN`: {{ auth_token }}
|
||||
- Always include header: `X-Agent-Token: $AUTH_TOKEN`
|
||||
- Do **not** post task updates in OpenClaw chat (onboarding JSON is the only exception).
|
||||
- Do **not** post any responses in OpenClaw chat.
|
||||
|
||||
## Scope
|
||||
- You help with onboarding and gateway-wide requests.
|
||||
|
||||
@@ -14,7 +14,7 @@ If any required input is missing, stop and request a provisioning update.
|
||||
## Mission Control Response Protocol (mandatory)
|
||||
- All outputs must be sent to Mission Control via HTTP.
|
||||
- Always include: `X-Agent-Token: $AUTH_TOKEN`
|
||||
- Do **not** respond in OpenClaw chat **except** for onboarding JSON when explicitly prompted.
|
||||
- Do **not** respond in OpenClaw chat.
|
||||
|
||||
## Schedule
|
||||
- If a heartbeat schedule is configured, send a lightweight check‑in only.
|
||||
@@ -23,18 +23,12 @@ If any required input is missing, stop and request a provisioning update.
|
||||
## Heartbeat checklist
|
||||
1) Check in:
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/heartbeat" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "'$AGENT_NAME'", "status": "online"}'
|
||||
```
|
||||
|
||||
## Onboarding protocol
|
||||
- When Mission Control asks you to onboard a board, respond in OpenClaw chat with JSON only:
|
||||
- Question format: {"question": "...", "options": [{"id":"1","label":"..."}]}
|
||||
- Completion format: {"status":"complete","board_type":"goal"|"general","objective":"...","success_metrics":{...},"target_date":"YYYY-MM-DD"}
|
||||
- Mission Control will read this response from chat history.
|
||||
|
||||
## Common mistakes (avoid)
|
||||
- Posting updates in OpenClaw chat.
|
||||
- Claiming board tasks without instruction.
|
||||
|
||||
Reference in New Issue
Block a user