feat: add is_board_lead property to agent and board types

This commit is contained in:
Abhimanyu Saharan
2026-02-05 19:06:32 +05:30
parent 9e8ff0dadf
commit 4cc6c42440
21 changed files with 767 additions and 360 deletions

239
backend/app/api/agent.py Normal file
View 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),
)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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.");
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,7 +215,8 @@ export default function EditBoardPage() {
};
return (
<DashboardShell>
<>
<DashboardShell>
<SignedOut>
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
@@ -229,126 +246,152 @@ export default function EditBoardPage() {
</div>
<div className="p-8">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !board}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={gatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board type
</label>
<Select value={boardType} onValueChange={setBoardType}>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target date
</label>
<Input
type="date"
value={targetDate}
onChange={(event) => setTargetDate(event.target.value)}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Objective
</label>
<Textarea
value={objective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<BoardGoalPanel
board={board}
onStartOnboarding={() => setIsOnboardingOpen(true)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Success metrics (JSON)
</label>
<Textarea
value={successMetrics}
onChange={(event) => setSuccessMetrics(event.target.value)}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
) : null}
</div>
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>No gateways available. Create one in Gateways to continue.</p>
</div>
) : null}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}`)}
disabled={isLoading}
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !board || !isFormReady}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !board}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={gatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board type
</label>
<Select value={boardType} onValueChange={setBoardType}>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target date
</label>
<Input
type="date"
value={targetDate}
onChange={(event) => setTargetDate(event.target.value)}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Objective
</label>
<Textarea
value={objective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Success metrics (JSON)
</label>
<Textarea
value={successMetrics}
onChange={(event) => setSuccessMetrics(event.target.value)}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
) : null}
</div>
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>No gateways available. Create one in Gateways to continue.</p>
</div>
) : null}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}`)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !board || !isFormReady}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
</div>
</form>
<div className="space-y-6">
{boardId ? <BoardApprovalsPanel boardId={boardId} /> : null}
</div>
</div>
</div>
</main>
</SignedIn>
</DashboardShell>
</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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -29,6 +29,7 @@ type Agent = {
board_id?: string | null;
last_seen_at?: string | null;
updated_at: string;
is_board_lead?: boolean;
};
type GatewayStatus = {

View File

@@ -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) => (
<Button
key={option.id}
variant="secondary"
className="w-full justify-start"
onClick={() => handleAnswer(option.label)}
disabled={loading}
>
{option.label}
</Button>
))}
{question.options.map((option) => {
const isSelected = selectedOptions.includes(option.label);
return (
<Button
key={option.id}
variant={isSelected ? "primary" : "secondary"}
className="w-full justify-start"
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>
) : (

View File

@@ -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"}'

View File

@@ -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.
## Preflight 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 twostep 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"}'
```

View File

@@ -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.
## Preflight 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 twostep 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"}'
```

View File

@@ -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.
## Preflight 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 twostep 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"}'
```

View File

@@ -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.

View File

@@ -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 checkin 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.