"""Board onboarding endpoints for user/agent collaboration.""" from __future__ import annotations import logging from typing import TYPE_CHECKING from fastapi import APIRouter, Depends, HTTPException, status from pydantic import ValidationError from sqlmodel import col from app.api.deps import ( ActorContext, get_board_for_user_read, get_board_for_user_write, get_board_or_404, require_admin_auth, require_admin_or_agent, ) from app.core.config import settings from app.core.time import utcnow 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 from app.models.board_onboarding import BoardOnboardingSession from app.models.gateways import Gateway from app.schemas.board_onboarding import ( BoardOnboardingAgentComplete, BoardOnboardingAgentUpdate, BoardOnboardingAnswer, BoardOnboardingConfirm, BoardOnboardingLeadAgentDraft, BoardOnboardingRead, BoardOnboardingStart, BoardOnboardingUserProfile, ) from app.schemas.boards import BoardRead from app.services.board_leads import LeadAgentOptions, LeadAgentRequest, ensure_board_lead_agent from app.services.gateway_agents import gateway_agent_session_key if TYPE_CHECKING: from sqlmodel.ext.asyncio.session import AsyncSession from app.core.auth import AuthContext from app.models.boards import Board router = APIRouter(prefix="/boards/{board_id}/onboarding", tags=["board-onboarding"]) logger = logging.getLogger(__name__) BOARD_USER_READ_DEP = Depends(get_board_for_user_read) BOARD_USER_WRITE_DEP = Depends(get_board_for_user_write) BOARD_OR_404_DEP = Depends(get_board_or_404) SESSION_DEP = Depends(get_session) ACTOR_DEP = Depends(require_admin_or_agent) ADMIN_AUTH_DEP = Depends(require_admin_auth) async def _gateway_config( session: AsyncSession, board: Board, ) -> tuple[Gateway, GatewayClientConfig]: if not board.gateway_id: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) gateway = await Gateway.objects.by_id(board.gateway_id).first(session) if gateway is None or not gateway.url: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token) def _parse_draft_user_profile( draft_goal: object, ) -> BoardOnboardingUserProfile | None: if not isinstance(draft_goal, dict): return None raw_profile = draft_goal.get("user_profile") if raw_profile is None: return None try: return BoardOnboardingUserProfile.model_validate(raw_profile) except ValidationError: return None def _parse_draft_lead_agent( draft_goal: object, ) -> BoardOnboardingLeadAgentDraft | None: if not isinstance(draft_goal, dict): return None raw_lead = draft_goal.get("lead_agent") if raw_lead is None: return None try: return BoardOnboardingLeadAgentDraft.model_validate(raw_lead) except ValidationError: return None def _apply_user_profile( auth: AuthContext, profile: BoardOnboardingUserProfile | None, ) -> bool: if auth.user is None or profile is None: return False changed = False if profile.preferred_name is not None: auth.user.preferred_name = profile.preferred_name changed = True if profile.pronouns is not None: auth.user.pronouns = profile.pronouns changed = True if profile.timezone is not None: auth.user.timezone = profile.timezone changed = True if profile.notes is not None: auth.user.notes = profile.notes changed = True if profile.context is not None: auth.user.context = profile.context changed = True return changed def _lead_agent_options( lead_agent: BoardOnboardingLeadAgentDraft | None, ) -> LeadAgentOptions: if lead_agent is None: return LeadAgentOptions(action="provision") lead_identity_profile: dict[str, str] = {} if lead_agent.identity_profile: lead_identity_profile.update(lead_agent.identity_profile) if lead_agent.autonomy_level: lead_identity_profile["autonomy_level"] = lead_agent.autonomy_level if lead_agent.verbosity: lead_identity_profile["verbosity"] = lead_agent.verbosity if lead_agent.output_format: lead_identity_profile["output_format"] = lead_agent.output_format if lead_agent.update_cadence: lead_identity_profile["update_cadence"] = lead_agent.update_cadence if lead_agent.custom_instructions: lead_identity_profile["custom_instructions"] = lead_agent.custom_instructions return LeadAgentOptions( agent_name=lead_agent.name, identity_profile=lead_identity_profile or None, action="provision", ) @router.get("", response_model=BoardOnboardingRead) async def get_onboarding( board: Board = BOARD_USER_READ_DEP, session: AsyncSession = SESSION_DEP, ) -> BoardOnboardingSession: """Get the latest onboarding session for a board.""" onboarding = ( await BoardOnboardingSession.objects.filter_by(board_id=board.id) .order_by(col(BoardOnboardingSession.updated_at).desc()) .first(session) ) if onboarding is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) return onboarding @router.post("/start", response_model=BoardOnboardingRead) async def start_onboarding( _payload: BoardOnboardingStart, board: Board = BOARD_USER_WRITE_DEP, session: AsyncSession = SESSION_DEP, ) -> BoardOnboardingSession: """Start onboarding and send instructions to the gateway agent.""" onboarding = ( await BoardOnboardingSession.objects.filter_by(board_id=board.id) .filter(col(BoardOnboardingSession.status) == "active") .first(session) ) if onboarding: return onboarding gateway, config = await _gateway_config(session, board) session_key = gateway_agent_session_key(gateway) base_url = settings.base_url or "http://localhost:8000" prompt = ( "BOARD ONBOARDING REQUEST\n\n" f"Board Name: {board.name}\n" "You are the gateway agent. Ask the user 6-10 focused questions total:\n" "- 3-6 questions to clarify the board goal.\n" "- 1 question to choose a unique name for the board lead agent " "(first-name style).\n" "- 2-4 questions to capture the user's preferences for how the board " "lead should work\n" " (communication style, autonomy, update cadence, and output formatting).\n" '- Always include a final question (and only once): "Anything else we ' 'should know?"\n' " (constraints, context, preferences). This MUST be the last question.\n" ' Provide an option like "Yes (I\'ll type it)" so they can enter free-text.\n' " Do NOT ask for additional context on earlier questions.\n" " Only include a free-text option on earlier questions if a typed " "answer is necessary;\n" ' when you do, make the option label include "I\'ll type it" ' '(e.g., "Other (I\'ll type it)").\n' '- If the user sends an "Additional context" message later, incorporate ' "it and resend status=complete\n" " to update the draft (until the user confirms).\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 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 \'{"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":{"metric":"...","target":"..."},' '"target_date":"YYYY-MM-DD",' '"user_profile":{"preferred_name":"...","pronouns":"...",' '"timezone":"...","notes":"...","context":"..."},' '"lead_agent":{"name":"Ava","identity_profile":{"role":"Board Lead",' '"communication_style":"direct, concise, practical","emoji":":gear:"},' '"autonomy_level":"balanced","verbosity":"concise",' '"output_format":"bullets","update_cadence":"daily",' '"custom_instructions":"..."}}\'\n' "ENUMS:\n" "- board_type: goal | general\n" "- lead_agent.autonomy_level: ask_first | balanced | autonomous\n" "- lead_agent.verbosity: concise | balanced | detailed\n" "- lead_agent.output_format: bullets | mixed | narrative\n" "- lead_agent.update_cadence: asap | hourly | daily | weekly\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, send one final response with status=complete.\n" "The completion payload must include board_type. If board_type=goal, " "include objective + success_metrics.\n" "Also include user_profile + lead_agent to configure the board lead's " "working style.\n" ) try: await ensure_session(session_key, config=config, label="Gateway Agent") await send_message( prompt, session_key=session_key, config=config, deliver=False, ) except OpenClawGatewayError as exc: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc), ) from exc onboarding = BoardOnboardingSession( board_id=board.id, session_key=session_key, status="active", messages=[ {"role": "user", "content": prompt, "timestamp": utcnow().isoformat()}, ], ) session.add(onboarding) await session.commit() await session.refresh(onboarding) return onboarding @router.post("/answer", response_model=BoardOnboardingRead) async def answer_onboarding( payload: BoardOnboardingAnswer, board: Board = BOARD_USER_WRITE_DEP, session: AsyncSession = SESSION_DEP, ) -> BoardOnboardingSession: """Send a user onboarding answer to the gateway agent.""" onboarding = ( await BoardOnboardingSession.objects.filter_by(board_id=board.id) .order_by(col(BoardOnboardingSession.updated_at).desc()) .first(session) ) if onboarding is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) _, config = await _gateway_config(session, board) answer_text = payload.answer if payload.other_text: answer_text = f"{payload.answer}: {payload.other_text}" messages = list(onboarding.messages or []) messages.append( {"role": "user", "content": answer_text, "timestamp": utcnow().isoformat()}, ) try: await ensure_session(onboarding.session_key, config=config, label="Gateway Agent") await send_message( answer_text, session_key=onboarding.session_key, config=config, deliver=False, ) except OpenClawGatewayError as exc: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc), ) from exc onboarding.messages = messages onboarding.updated_at = utcnow() session.add(onboarding) await session.commit() await session.refresh(onboarding) return onboarding @router.post("/agent", response_model=BoardOnboardingRead) async def agent_onboarding_update( payload: BoardOnboardingAgentUpdate, board: Board = BOARD_OR_404_DEP, session: AsyncSession = SESSION_DEP, actor: ActorContext = ACTOR_DEP, ) -> BoardOnboardingSession: """Store onboarding updates submitted by the gateway agent.""" 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 = await Gateway.objects.by_id(board.gateway_id).first(session) if ( gateway and (agent.gateway_id != gateway.id or agent.board_id is not None) ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) onboarding = ( await BoardOnboardingSession.objects.filter_by(board_id=board.id) .order_by(col(BoardOnboardingSession.updated_at).desc()) .first(session) ) 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 = utcnow().isoformat() payload_text = payload.model_dump_json(exclude_none=True) payload_data = payload.model_dump(mode="json", exclude_none=True) logger.info( "onboarding.agent.update board_id=%s agent_id=%s payload=%s", board.id, agent.id, payload_text, ) if isinstance(payload, BoardOnboardingAgentComplete): onboarding.draft_goal = payload_data onboarding.status = "completed" messages.append( {"role": "assistant", "content": payload_text, "timestamp": now}, ) else: messages.append( {"role": "assistant", "content": payload_text, "timestamp": now}, ) onboarding.messages = messages onboarding.updated_at = utcnow() session.add(onboarding) await session.commit() await 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, board: Board = BOARD_USER_WRITE_DEP, session: AsyncSession = SESSION_DEP, auth: AuthContext = ADMIN_AUTH_DEP, ) -> Board: """Confirm onboarding results and provision the board lead agent.""" onboarding = ( await BoardOnboardingSession.objects.filter_by(board_id=board.id) .order_by(col(BoardOnboardingSession.updated_at).desc()) .first(session) ) if onboarding is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) board.board_type = payload.board_type board.objective = payload.objective board.success_metrics = payload.success_metrics board.target_date = payload.target_date board.goal_confirmed = True board.goal_source = "lead_agent_onboarding" onboarding.status = "confirmed" onboarding.updated_at = utcnow() user_profile = _parse_draft_user_profile(onboarding.draft_goal) if _apply_user_profile(auth, user_profile) and auth.user is not None: session.add(auth.user) lead_agent = _parse_draft_lead_agent(onboarding.draft_goal) lead_options = _lead_agent_options(lead_agent) gateway, config = await _gateway_config(session, board) session.add(board) session.add(onboarding) await session.commit() await session.refresh(board) await ensure_board_lead_agent( session, request=LeadAgentRequest( board=board, gateway=gateway, config=config, user=auth.user, options=lead_options, ), ) return board