From a33c5398600b31cbe5fba471ecc20f2fe3462b71 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 4 Feb 2026 14:58:14 +0530 Subject: [PATCH] feat: enhance agent management with session handling and UI improvements --- backend/app/api/activity.py | 11 +- backend/app/api/agents.py | 92 ++- backend/app/api/boards.py | 11 +- backend/app/api/deps.py | 27 +- backend/app/api/gateway.py | 54 +- backend/app/api/tasks.py | 26 +- backend/app/core/auth.py | 43 ++ backend/app/integrations/openclaw_gateway.py | 11 + backend/app/models/agents.py | 1 + backend/app/services/agent_provisioning.py | 10 +- .../src/app/agents/[agentId]/edit/page.tsx | 183 +++++ frontend/src/app/agents/[agentId]/page.tsx | 357 ++++++++++ frontend/src/app/agents/new/page.tsx | 151 ++++ frontend/src/app/agents/page.tsx | 663 +++++++----------- templates/HEARTBEAT.md | 12 +- templates/IDENTITY.md | 1 - 16 files changed, 1181 insertions(+), 472 deletions(-) create mode 100644 frontend/src/app/agents/[agentId]/edit/page.tsx create mode 100644 frontend/src/app/agents/[agentId]/page.tsx create mode 100644 frontend/src/app/agents/new/page.tsx diff --git a/backend/app/api/activity.py b/backend/app/api/activity.py index 966cba38..6b8d461e 100644 --- a/backend/app/api/activity.py +++ b/backend/app/api/activity.py @@ -4,8 +4,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import desc from sqlmodel import Session, col, select -from app.api.deps import require_admin_auth -from app.core.auth import AuthContext +from app.api.deps import ActorContext, require_admin_or_agent from app.db.session import get_session from app.models.activity_events import ActivityEvent from app.schemas.activity_events import ActivityEventRead @@ -18,11 +17,13 @@ def list_activity( limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), session: Session = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + actor: ActorContext = Depends(require_admin_or_agent), ) -> list[ActivityEvent]: + statement = select(ActivityEvent) + if actor.actor_type == "agent" and actor.agent: + statement = statement.where(ActivityEvent.agent_id == actor.agent.id) statement = ( - select(ActivityEvent) - .order_by(desc(col(ActivityEvent.created_at))) + statement.order_by(desc(col(ActivityEvent.created_at))) .offset(offset) .limit(limit) ) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 8992288d..d994fb5b 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -5,13 +5,22 @@ from datetime import datetime, timedelta from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, status -from sqlmodel import Session, select +from sqlmodel import Session, col, select +from sqlalchemy import update -from app.api.deps import require_admin_auth +from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent +from app.core.agent_tokens import generate_agent_token, hash_agent_token from app.core.auth import AuthContext +from app.core.config import settings from app.db.session import get_session -from app.integrations.openclaw_gateway import OpenClawGatewayError, openclaw_call +from app.integrations.openclaw_gateway import ( + OpenClawGatewayError, + delete_session, + ensure_session, + send_message, +) from app.models.agents import Agent +from app.models.activity_events import ActivityEvent from app.schemas.agents import ( AgentCreate, AgentHeartbeat, @@ -40,7 +49,7 @@ def _build_session_key(agent_name: str) -> str: async def _ensure_gateway_session(agent_name: str) -> tuple[str, str | None]: session_key = _build_session_key(agent_name) try: - await openclaw_call("sessions.patch", {"key": session_key, "label": agent_name}) + await ensure_session(session_key, label=agent_name) return session_key, None except OpenClawGatewayError as exc: return session_key, str(exc) @@ -87,6 +96,8 @@ async def create_agent( auth: AuthContext = Depends(require_admin_auth), ) -> Agent: agent = Agent.model_validate(payload) + raw_token = generate_agent_token() + agent.agent_token_hash = hash_agent_token(raw_token) session_key, session_error = await _ensure_gateway_session(agent.name) agent.openclaw_session_id = session_key session.add(agent) @@ -108,7 +119,7 @@ async def create_agent( ) session.commit() try: - await send_provisioning_message(agent) + await send_provisioning_message(agent, raw_token) except OpenClawGatewayError as exc: _record_provisioning_failure(session, agent, str(exc)) session.commit() @@ -155,11 +166,13 @@ def heartbeat_agent( agent_id: str, payload: AgentHeartbeat, session: Session = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + actor: ActorContext = Depends(require_admin_or_agent), ) -> Agent: agent = session.get(Agent, agent_id) if agent is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) if payload.status: agent.status = payload.status agent.last_seen_at = datetime.utcnow() @@ -175,11 +188,15 @@ def heartbeat_agent( async def heartbeat_or_create_agent( payload: AgentHeartbeatCreate, session: Session = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + actor: ActorContext = Depends(require_admin_or_agent), ) -> Agent: agent = session.exec(select(Agent).where(Agent.name == payload.name)).first() if agent is None: + if actor.actor_type == "agent": + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) agent = Agent(name=payload.name, status=payload.status or "online") + raw_token = generate_agent_token() + agent.agent_token_hash = hash_agent_token(raw_token) session_key, session_error = await _ensure_gateway_session(agent.name) agent.openclaw_session_id = session_key session.add(agent) @@ -201,7 +218,23 @@ async def heartbeat_or_create_agent( ) session.commit() try: - await send_provisioning_message(agent) + await send_provisioning_message(agent, raw_token) + except OpenClawGatewayError as exc: + _record_provisioning_failure(session, agent, str(exc)) + session.commit() + except Exception as exc: # pragma: no cover - unexpected provisioning errors + _record_provisioning_failure(session, agent, str(exc)) + session.commit() + elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + elif agent.agent_token_hash is None and actor.actor_type == "user": + raw_token = generate_agent_token() + agent.agent_token_hash = hash_agent_token(raw_token) + session.add(agent) + session.commit() + session.refresh(agent) + try: + await send_provisioning_message(agent, raw_token) except OpenClawGatewayError as exc: _record_provisioning_failure(session, agent, str(exc)) session.commit() @@ -226,14 +259,6 @@ async def heartbeat_or_create_agent( agent_id=agent.id, ) session.commit() - try: - await send_provisioning_message(agent) - except OpenClawGatewayError as exc: - _record_provisioning_failure(session, agent, str(exc)) - session.commit() - except Exception as exc: # pragma: no cover - unexpected provisioning errors - _record_provisioning_failure(session, agent, str(exc)) - session.commit() if payload.status: agent.status = payload.status agent.last_seen_at = datetime.utcnow() @@ -253,6 +278,41 @@ def delete_agent( ) -> dict[str, bool]: agent = session.get(Agent, agent_id) if agent: + async def _gateway_cleanup() -> None: + if agent.openclaw_session_id: + await delete_session(agent.openclaw_session_id) + main_session = settings.openclaw_main_session_key + if main_session: + workspace_root = settings.openclaw_workspace_root or "~/.openclaw/workspaces" + workspace_path = f"{workspace_root.rstrip('/')}/{_slugify(agent.name)}" + cleanup_message = ( + "Cleanup request for deleted agent.\n\n" + f"Agent name: {agent.name}\n" + f"Agent id: {agent.id}\n" + f"Session key: {agent.openclaw_session_id or _build_session_key(agent.name)}\n" + f"Workspace path: {workspace_path}\n\n" + "Actions:\n" + "1) Remove the workspace directory.\n" + "2) Delete any lingering session artifacts.\n" + "Reply NO_REPLY." + ) + await ensure_session(main_session, label="Main Agent") + await send_message(cleanup_message, session_key=main_session, deliver=False) + + try: + import asyncio + + asyncio.run(_gateway_cleanup()) + except OpenClawGatewayError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Gateway cleanup failed: {exc}", + ) from exc + session.execute( + update(ActivityEvent) + .where(col(ActivityEvent.agent_id) == agent.id) + .values(agent_id=None) + ) session.delete(agent) session.commit() return {"ok": True} diff --git a/backend/app/api/boards.py b/backend/app/api/boards.py index f1bec3d4..3f4a4e60 100644 --- a/backend/app/api/boards.py +++ b/backend/app/api/boards.py @@ -3,7 +3,12 @@ from __future__ import annotations from fastapi import APIRouter, Depends 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.auth import AuthContext from app.db.session import get_session from app.models.boards import Board @@ -15,7 +20,7 @@ router = APIRouter(prefix="/boards", tags=["boards"]) @router.get("", response_model=list[BoardRead]) def list_boards( session: Session = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + actor: ActorContext = Depends(require_admin_or_agent), ) -> list[Board]: return list(session.exec(select(Board))) @@ -36,7 +41,7 @@ def create_board( @router.get("/{board_id}", response_model=BoardRead) def get_board( board: Board = Depends(get_board_or_404), - auth: AuthContext = Depends(require_admin_auth), + actor: ActorContext = Depends(require_admin_or_agent), ) -> Board: return board diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 2aa4b41c..c1a68d56 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,12 +1,18 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Literal + from fastapi import Depends, HTTPException, status from sqlmodel import Session -from app.core.auth import AuthContext, get_auth_context +from app.core.agent_auth import AgentAuthContext, get_agent_auth_context_optional +from app.core.auth import AuthContext, get_auth_context, get_auth_context_optional from app.db.session import get_session +from app.models.agents import Agent from app.models.boards import Board from app.models.tasks import Task +from app.models.users import User from app.services.admin_access import require_admin @@ -15,6 +21,25 @@ def require_admin_auth(auth: AuthContext = Depends(get_auth_context)) -> AuthCon return auth +@dataclass +class ActorContext: + actor_type: Literal["user", "agent"] + user: User | None = None + agent: Agent | None = None + + +def require_admin_or_agent( + auth: AuthContext | None = Depends(get_auth_context_optional), + agent_auth: AgentAuthContext | None = Depends(get_agent_auth_context_optional), +) -> ActorContext: + if auth is not None: + require_admin(auth) + return ActorContext(actor_type="user", user=auth.user) + if agent_auth is not None: + return ActorContext(actor_type="agent", agent=agent_auth.agent) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + def get_board_or_404( board_id: str, session: Session = Depends(get_session), diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index c572019f..8d5d09a6 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -7,6 +7,7 @@ from app.core.auth import AuthContext from app.core.config import settings from app.integrations.openclaw_gateway import ( OpenClawGatewayError, + ensure_session, get_chat_history, openclaw_call, send_message, @@ -24,11 +25,24 @@ async def gateway_status(auth: AuthContext = Depends(require_admin_auth)) -> dic sessions_list = list(sessions.get("sessions") or []) else: sessions_list = list(sessions or []) + main_session = settings.openclaw_main_session_key + main_session_entry: object | None = None + main_session_error: str | None = None + if main_session: + try: + ensured = await ensure_session(main_session, label="Main Agent") + if isinstance(ensured, dict): + main_session_entry = ensured.get("entry") or ensured + except OpenClawGatewayError as exc: + main_session_error = str(exc) return { "connected": True, "gateway_url": gateway_url, "sessions_count": len(sessions_list), "sessions": sessions_list, + "main_session_key": main_session, + "main_session": main_session_entry, + "main_session_error": main_session_error, } except OpenClawGatewayError as exc: return { @@ -45,8 +59,21 @@ async def list_sessions(auth: AuthContext = Depends(require_admin_auth)) -> dict except OpenClawGatewayError as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc if isinstance(sessions, dict): - return {"sessions": list(sessions.get("sessions") or [])} - return {"sessions": list(sessions or [])} + sessions_list = list(sessions.get("sessions") or []) + else: + sessions_list = list(sessions or []) + + main_session = settings.openclaw_main_session_key + main_session_entry: object | None = None + if main_session: + try: + ensured = await ensure_session(main_session, label="Main Agent") + if isinstance(ensured, dict): + main_session_entry = ensured.get("entry") or ensured + except OpenClawGatewayError: + main_session_entry = None + + return {"sessions": sessions_list, "main_session_key": main_session, "main_session": main_session_entry} @router.get("/sessions/{session_id}") @@ -61,7 +88,27 @@ async def get_session( sessions_list = list(sessions.get("sessions") or []) else: sessions_list = list(sessions or []) + main_session = settings.openclaw_main_session_key + if main_session and not any( + session.get("key") == main_session for session in sessions_list + ): + try: + await ensure_session(main_session, label="Main Agent") + refreshed = await openclaw_call("sessions.list") + if isinstance(refreshed, dict): + sessions_list = list(refreshed.get("sessions") or []) + else: + sessions_list = list(refreshed or []) + except OpenClawGatewayError: + pass session = next((item for item in sessions_list if item.get("key") == session_id), None) + if session is None and main_session and session_id == main_session: + try: + ensured = await ensure_session(main_session, label="Main Agent") + if isinstance(ensured, dict): + session = ensured.get("entry") or ensured + except OpenClawGatewayError: + session = None if session is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") return {"session": session} @@ -92,6 +139,9 @@ async def send_session_message( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="content is required" ) try: + main_session = settings.openclaw_main_session_key + if main_session and session_id == main_session: + await ensure_session(main_session, label="Main Agent") await send_message(content, session_key=session_id) except OpenClawGatewayError as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 7212e21e..cf570602 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -2,10 +2,16 @@ from __future__ import annotations from datetime import datetime -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel import Session, select -from app.api.deps import get_board_or_404, get_task_or_404, require_admin_auth +from app.api.deps import ( + ActorContext, + get_board_or_404, + get_task_or_404, + require_admin_auth, + require_admin_or_agent, +) from app.core.auth import AuthContext from app.db.session import get_session from app.models.boards import Board @@ -20,7 +26,7 @@ router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"]) def list_tasks( board: Board = Depends(get_board_or_404), session: Session = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + actor: ActorContext = Depends(require_admin_or_agent), ) -> list[Task]: return list(session.exec(select(Task).where(Task.board_id == board.id))) @@ -55,10 +61,14 @@ def update_task( payload: TaskUpdate, task: Task = Depends(get_task_or_404), session: Session = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + actor: ActorContext = Depends(require_admin_or_agent), ) -> Task: previous_status = task.status updates = payload.model_dump(exclude_unset=True) + if actor.actor_type == "agent": + allowed_fields = {"status"} + if not set(updates).issubset(allowed_fields): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) for key, value in updates.items(): setattr(task, key, value) task.updated_at = datetime.utcnow() @@ -73,7 +83,13 @@ def update_task( else: event_type = "task.updated" message = f"Task updated: {task.title}." - record_activity(session, event_type=event_type, task_id=task.id, message=message) + record_activity( + session, + event_type=event_type, + task_id=task.id, + message=message, + agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None, + ) session.commit() return task diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 1efc62f3..dc9b764c 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -95,3 +95,46 @@ async def get_auth_context( actor_type="user", user=user, ) + + +async def get_auth_context_optional( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(security), + session: Session = Depends(get_session), +) -> AuthContext | None: + if credentials is None: + return None + + try: + guard = _build_clerk_http_bearer(auto_error=False) + 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 + + 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 + + if not clerk_user_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + user = session.exec(select(User).where(User.clerk_user_id == clerk_user_id)).first() + if user is None: + claims = auth_data.decoded if auth_data and auth_data.decoded else {} + user = User( + clerk_user_id=clerk_user_id, + email=claims.get("email"), + name=claims.get("name"), + ) + session.add(user) + session.commit() + session.refresh(user) + + return AuthContext( + actor_type="user", + user=user, + ) diff --git a/backend/app/integrations/openclaw_gateway.py b/backend/app/integrations/openclaw_gateway.py index 06c0eae6..1793a61d 100644 --- a/backend/app/integrations/openclaw_gateway.py +++ b/backend/app/integrations/openclaw_gateway.py @@ -126,3 +126,14 @@ async def get_chat_history(session_key: str, limit: int | None = None) -> Any: if limit is not None: params["limit"] = limit return await openclaw_call("chat.history", params) + + +async def delete_session(session_key: str) -> Any: + return await openclaw_call("sessions.delete", {"key": session_key}) + + +async def ensure_session(session_key: str, label: str | None = None) -> Any: + params: dict[str, Any] = {"key": session_key} + if label: + params["label"] = label + return await openclaw_call("sessions.patch", params) diff --git a/backend/app/models/agents.py b/backend/app/models/agents.py index 56271898..6ac7f21a 100644 --- a/backend/app/models/agents.py +++ b/backend/app/models/agents.py @@ -13,6 +13,7 @@ class Agent(SQLModel, table=True): name: str = Field(index=True) status: str = Field(default="online", index=True) openclaw_session_id: str | None = Field(default=None, index=True) + agent_token_hash: str | None = Field(default=None, index=True) last_seen_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/app/services/agent_provisioning.py b/backend/app/services/agent_provisioning.py index 0f587899..66826081 100644 --- a/backend/app/services/agent_provisioning.py +++ b/backend/app/services/agent_provisioning.py @@ -7,7 +7,7 @@ from uuid import uuid4 from jinja2 import Environment, FileSystemLoader, StrictUndefined from app.core.config import settings -from app.integrations.openclaw_gateway import send_message +from app.integrations.openclaw_gateway import ensure_session, send_message from app.models.agents import Agent TEMPLATE_FILES = [ @@ -68,12 +68,11 @@ def _workspace_path(agent_name: str) -> str: return f"{root}/{_slugify(agent_name)}" -def build_provisioning_message(agent: Agent) -> str: +def build_provisioning_message(agent: Agent, auth_token: str) -> str: agent_id = str(agent.id) workspace_path = _workspace_path(agent.name) session_key = agent.openclaw_session_id or "" base_url = settings.base_url or "REPLACE_WITH_BASE_URL" - auth_token = "REPLACE_WITH_AUTH_TOKEN" context = { "agent_name": agent.name, @@ -114,9 +113,10 @@ def build_provisioning_message(agent: Agent) -> str: ) -async def send_provisioning_message(agent: Agent) -> None: +async def send_provisioning_message(agent: Agent, auth_token: str) -> None: main_session = settings.openclaw_main_session_key if not main_session: return - message = build_provisioning_message(agent) + await ensure_session(main_session, label="Main Agent") + message = build_provisioning_message(agent, auth_token) await send_message(message, session_key=main_session, deliver=False) diff --git a/frontend/src/app/agents/[agentId]/edit/page.tsx b/frontend/src/app/agents/[agentId]/edit/page.tsx new file mode 100644 index 00000000..f344ae18 --- /dev/null +++ b/frontend/src/app/agents/[agentId]/edit/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; + +import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; + +import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; +import { DashboardShell } from "@/components/templates/DashboardShell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const apiBase = + process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") || + "http://localhost:8000"; + +type Agent = { + id: string; + name: string; + status: string; +}; + +const statusOptions = [ + { value: "online", label: "Online" }, + { value: "busy", label: "Busy" }, + { value: "offline", label: "Offline" }, +]; + +export default function EditAgentPage() { + const { getToken, isSignedIn } = useAuth(); + const router = useRouter(); + const params = useParams(); + const agentIdParam = params?.agentId; + const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam; + + const [agent, setAgent] = useState(null); + const [name, setName] = useState(""); + const [status, setStatus] = useState("online"); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const loadAgent = async () => { + if (!isSignedIn || !agentId) return; + setIsLoading(true); + setError(null); + try { + const token = await getToken(); + const response = await fetch(`${apiBase}/api/v1/agents/${agentId}`, { + headers: { Authorization: token ? `Bearer ${token}` : "" }, + }); + if (!response.ok) { + throw new Error("Unable to load agent."); + } + const data = (await response.json()) as Agent; + setAgent(data); + setName(data.name); + setStatus(data.status); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadAgent(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSignedIn, agentId]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!isSignedIn || !agentId) return; + const trimmed = name.trim(); + if (!trimmed) { + setError("Agent name is required."); + return; + } + setIsLoading(true); + setError(null); + try { + const token = await getToken(); + const response = await fetch(`${apiBase}/api/v1/agents/${agentId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + body: JSON.stringify({ name: trimmed, status }), + }); + if (!response.ok) { + throw new Error("Unable to update agent."); + } + router.push(`/agents/${agentId}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsLoading(false); + } + }; + + return ( + + +
+

Sign in to edit agents.

+ + + +
+
+ + +
+
+

+ Edit agent +

+

+ {agent?.name ?? "Agent"} +

+

+ Update the agent name and status. +

+
+
+
+ + setName(event.target.value)} + placeholder="e.g. Deploy bot" + disabled={isLoading} + /> +
+
+ + +
+ {error ? ( +
+ {error} +
+ ) : null} + +
+ +
+
+
+ ); +} diff --git a/frontend/src/app/agents/[agentId]/page.tsx b/frontend/src/app/agents/[agentId]/page.tsx new file mode 100644 index 00000000..169aba04 --- /dev/null +++ b/frontend/src/app/agents/[agentId]/page.tsx @@ -0,0 +1,357 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; + +import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; + +import { StatusPill } from "@/components/atoms/StatusPill"; +import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; +import { DashboardShell } from "@/components/templates/DashboardShell"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +const apiBase = + process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") || + "http://localhost:8000"; + +type Agent = { + id: string; + name: string; + status: string; + openclaw_session_id?: string | null; + last_seen_at: string; + created_at: string; + updated_at: string; +}; + +type ActivityEvent = { + id: string; + event_type: string; + message?: string | null; + agent_id?: string | null; + created_at: string; +}; + +const formatTimestamp = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +const formatRelative = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + const diff = Date.now() - date.getTime(); + const minutes = Math.round(diff / 60000); + if (minutes < 1) return "Just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days}d ago`; +}; + +export default function AgentDetailPage() { + const { getToken, isSignedIn } = useAuth(); + const router = useRouter(); + const params = useParams(); + const agentIdParam = params?.agentId; + const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam; + + const [agent, setAgent] = useState(null); + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const [deleteOpen, setDeleteOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + const agentEvents = useMemo(() => { + if (!agent) return []; + return events.filter((event) => event.agent_id === agent.id); + }, [events, agent]); + + const loadAgent = async () => { + if (!isSignedIn || !agentId) return; + setIsLoading(true); + setError(null); + try { + const token = await getToken(); + const [agentResponse, activityResponse] = await Promise.all([ + fetch(`${apiBase}/api/v1/agents/${agentId}`, { + headers: { Authorization: token ? `Bearer ${token}` : "" }, + }), + fetch(`${apiBase}/api/v1/activity?limit=200`, { + headers: { Authorization: token ? `Bearer ${token}` : "" }, + }), + ]); + if (!agentResponse.ok) { + throw new Error("Unable to load agent."); + } + if (!activityResponse.ok) { + throw new Error("Unable to load activity."); + } + const agentData = (await agentResponse.json()) as Agent; + const eventsData = (await activityResponse.json()) as ActivityEvent[]; + setAgent(agentData); + setEvents(eventsData); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadAgent(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSignedIn, agentId]); + + const handleDelete = async () => { + if (!agent || !isSignedIn) return; + setIsDeleting(true); + setDeleteError(null); + try { + const token = await getToken(); + const response = await fetch(`${apiBase}/api/v1/agents/${agent.id}`, { + method: "DELETE", + headers: { Authorization: token ? `Bearer ${token}` : "" }, + }); + if (!response.ok) { + throw new Error("Unable to delete agent."); + } + router.push("/agents"); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsDeleting(false); + } + }; + + return ( + + +
+

Sign in to view agents.

+ + + +
+
+ + +
+
+
+

+ Agents +

+

+ {agent?.name ?? "Agent"} +

+

+ Review agent health, session binding, and recent activity. +

+
+
+ + {agent ? ( + + Edit + + ) : null} + {agent ? ( + + ) : null} +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {isLoading ? ( +
+ Loading agent details… +
+ ) : agent ? ( +
+
+
+
+
+

+ Overview +

+

+ {agent.name} +

+
+ +
+
+
+

+ Agent ID +

+

{agent.id}

+
+
+

+ Session key +

+

+ {agent.openclaw_session_id ?? "—"} +

+
+
+

+ Last seen +

+

+ {formatRelative(agent.last_seen_at)} +

+

+ {formatTimestamp(agent.last_seen_at)} +

+
+
+

+ Updated +

+

+ {formatTimestamp(agent.updated_at)} +

+
+
+

+ Created +

+

+ {formatTimestamp(agent.created_at)} +

+
+
+
+ +
+
+

+ Health +

+ +
+
+
+ Heartbeat window + {formatRelative(agent.last_seen_at)} +
+
+ Session binding + {agent.openclaw_session_id ? "Bound" : "Unbound"} +
+
+ Status + {agent.status} +
+
+
+
+ +
+
+

+ Activity +

+

+ {agentEvents.length} events +

+
+
+ {agentEvents.length === 0 ? ( +
+ No activity yet for this agent. +
+ ) : ( + agentEvents.map((event) => ( +
+

+ {event.message ?? event.event_type} +

+

+ {formatTimestamp(event.created_at)} +

+
+ )) + )} +
+
+
+ ) : ( +
+ Agent not found. +
+ )} +
+
+ + + + + Delete agent + + This will remove {agent?.name}. This action cannot be undone. + + + {deleteError ? ( +
+ {deleteError} +
+ ) : null} + + + + +
+
+
+ ); +} diff --git a/frontend/src/app/agents/new/page.tsx b/frontend/src/app/agents/new/page.tsx new file mode 100644 index 00000000..128aa044 --- /dev/null +++ b/frontend/src/app/agents/new/page.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; + +import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; +import { DashboardShell } from "@/components/templates/DashboardShell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const apiBase = + process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") || + "http://localhost:8000"; + +type Agent = { + id: string; + name: string; +}; + +const statusOptions = [ + { value: "online", label: "Online" }, + { value: "busy", label: "Busy" }, + { value: "offline", label: "Offline" }, +]; + +export default function NewAgentPage() { + const router = useRouter(); + const { getToken, isSignedIn } = useAuth(); + + const [name, setName] = useState(""); + const [status, setStatus] = useState("online"); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!isSignedIn) return; + const trimmed = name.trim(); + if (!trimmed) { + setError("Agent name is required."); + return; + } + setIsLoading(true); + setError(null); + try { + const token = await getToken(); + const response = await fetch(`${apiBase}/api/v1/agents`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + body: JSON.stringify({ name: trimmed, status }), + }); + if (!response.ok) { + throw new Error("Unable to create agent."); + } + const created = (await response.json()) as Agent; + router.push(`/agents/${created.id}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsLoading(false); + } + }; + + return ( + + +
+

Sign in to create an agent.

+ + + +
+
+ + +
+
+

+ New agent +

+

+ Register an agent. +

+

+ Add an agent to your mission control roster. +

+
+
+
+ + setName(event.target.value)} + placeholder="e.g. Deploy bot" + disabled={isLoading} + /> +
+
+ + +
+ {error ? ( +
+ {error} +
+ ) : null} + +
+ +
+
+
+ ); +} diff --git a/frontend/src/app/agents/page.tsx b/frontend/src/app/agents/page.tsx index bf400cad..3e725ce5 100644 --- a/frontend/src/app/agents/page.tsx +++ b/frontend/src/app/agents/page.tsx @@ -1,9 +1,18 @@ "use client"; import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; +import { + type ColumnDef, + type SortingState, + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; import { StatusPill } from "@/components/atoms/StatusPill"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; @@ -17,28 +26,19 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +const apiBase = + process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") || + "http://localhost:8000"; type Agent = { id: string; name: string; status: string; + openclaw_session_id?: string | null; last_seen_at: string; -}; - -type ActivityEvent = { - id: string; - event_type: string; - message?: string | null; created_at: string; + updated_at: string; }; type GatewayStatus = { @@ -49,16 +49,6 @@ type GatewayStatus = { error?: string; }; -const apiBase = - process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") || - "http://localhost:8000"; - -const statusOptions = [ - { value: "online", label: "Online" }, - { value: "busy", label: "Busy" }, - { value: "offline", label: "Offline" }, -]; - const formatTimestamp = (value: string) => { const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; @@ -83,72 +73,47 @@ const formatRelative = (value: string) => { return `${days}d ago`; }; -const getSessionKey = ( - session: Record, - index: number -) => { - const key = session.key; - if (typeof key === "string" && key.length > 0) { - return key; - } - const sessionId = session.sessionId; - if (typeof sessionId === "string" && sessionId.length > 0) { - return sessionId; - } - return `session-${index}`; +const truncate = (value?: string | null, max = 18) => { + if (!value) return "—"; + if (value.length <= max) return value; + return `${value.slice(0, max)}…`; }; export default function AgentsPage() { const { getToken, isSignedIn } = useAuth(); const router = useRouter(); + const [agents, setAgents] = useState([]); - const [events, setEvents] = useState([]); + const [sorting, setSorting] = useState([ + { id: "name", desc: false }, + ]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [gatewayStatus, setGatewayStatus] = useState(null); - const [gatewaySessions, setGatewaySessions] = useState< - Record[] - >([]); const [gatewayError, setGatewayError] = useState(null); - const [selectedSession, setSelectedSession] = useState< - Record | null - >(null); - const [sessionHistory, setSessionHistory] = useState([]); - const [message, setMessage] = useState(""); - const [isSending, setIsSending] = useState(false); - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [name, setName] = useState(""); - const [status, setStatus] = useState("online"); - const [createError, setCreateError] = useState(null); - const [isCreating, setIsCreating] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); - const sortedAgents = useMemo( - () => [...agents].sort((a, b) => a.name.localeCompare(b.name)), - [agents], - ); + const sortedAgents = useMemo(() => [...agents], [agents]); - const loadData = async () => { + const loadAgents = async () => { if (!isSignedIn) return; setIsLoading(true); setError(null); try { const token = await getToken(); - const [agentsResponse, activityResponse] = await Promise.all([ - fetch(`${apiBase}/api/v1/agents`, { - headers: { Authorization: token ? `Bearer ${token}` : "" }, - }), - fetch(`${apiBase}/api/v1/activity`, { - headers: { Authorization: token ? `Bearer ${token}` : "" }, - }), - ]); - if (!agentsResponse.ok || !activityResponse.ok) { - throw new Error("Unable to load operational data."); + const response = await fetch(`${apiBase}/api/v1/agents`, { + headers: { + Authorization: token ? `Bearer ${token}` : "", + }, + }); + if (!response.ok) { + throw new Error("Unable to load agents."); } - const agentsData = (await agentsResponse.json()) as Agent[]; - const eventsData = (await activityResponse.json()) as ActivityEvent[]; - setAgents(agentsData); - setEvents(eventsData); + const data = (await response.json()) as Agent[]; + setAgents(data); } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong."); } finally { @@ -156,7 +121,7 @@ export default function AgentsPage() { } }; - const loadGateway = async () => { + const loadGatewayStatus = async () => { if (!isSignedIn) return; setGatewayError(null); try { @@ -169,119 +134,136 @@ export default function AgentsPage() { } const statusData = (await response.json()) as GatewayStatus; setGatewayStatus(statusData); - setGatewaySessions(statusData.sessions ?? []); - } catch (err) { - setGatewayError(err instanceof Error ? err.message : "Something went wrong."); - } - }; - - const loadSessionHistory = async (sessionId: string) => { - if (!isSignedIn) return; - try { - const token = await getToken(); - const response = await fetch( - `${apiBase}/api/v1/gateway/sessions/${sessionId}/history`, - { - headers: { Authorization: token ? `Bearer ${token}` : "" }, - } - ); - if (!response.ok) { - throw new Error("Unable to load session history."); - } - const data = (await response.json()) as { history?: unknown[] }; - setSessionHistory(data.history ?? []); } catch (err) { setGatewayError(err instanceof Error ? err.message : "Something went wrong."); } }; useEffect(() => { - loadData(); - loadGateway(); + loadAgents(); + loadGatewayStatus(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSignedIn]); - const resetForm = () => { - setName(""); - setStatus("online"); - setCreateError(null); - }; - - const handleCreate = async () => { - if (!isSignedIn) return; - const trimmed = name.trim(); - if (!trimmed) { - setCreateError("Agent name is required."); - return; - } - setIsCreating(true); - setCreateError(null); + const handleDelete = async () => { + if (!deleteTarget || !isSignedIn) return; + setIsDeleting(true); + setDeleteError(null); try { const token = await getToken(); - const response = await fetch(`${apiBase}/api/v1/agents`, { - method: "POST", + const response = await fetch(`${apiBase}/api/v1/agents/${deleteTarget.id}`, { + method: "DELETE", headers: { - "Content-Type": "application/json", Authorization: token ? `Bearer ${token}` : "", }, - body: JSON.stringify({ name: trimmed, status }), }); if (!response.ok) { - throw new Error("Unable to create agent."); + throw new Error("Unable to delete agent."); } - const created = (await response.json()) as Agent; - setAgents((prev) => [created, ...prev]); - setIsDialogOpen(false); - resetForm(); + setAgents((prev) => prev.filter((agent) => agent.id !== deleteTarget.id)); + setDeleteTarget(null); } catch (err) { - setCreateError(err instanceof Error ? err.message : "Something went wrong."); + setDeleteError(err instanceof Error ? err.message : "Something went wrong."); } finally { - setIsCreating(false); + setIsDeleting(false); } }; - const handleSendMessage = async () => { - if (!isSignedIn || !selectedSession) return; - const content = message.trim(); - if (!content) return; - setIsSending(true); - setGatewayError(null); - try { - const token = await getToken(); - const sessionId = selectedSession.key as string | undefined; - if (!sessionId) { - throw new Error("Missing session id."); - } - const response = await fetch( - `${apiBase}/api/v1/gateway/sessions/${sessionId}/message`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: token ? `Bearer ${token}` : "", - }, - body: JSON.stringify({ content }), - } - ); - if (!response.ok) { - throw new Error("Unable to send message."); - } - setMessage(""); - loadSessionHistory(sessionId); - } catch (err) { - setGatewayError(err instanceof Error ? err.message : "Something went wrong."); - } finally { - setIsSending(false); - } - }; + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: "Agent", + cell: ({ row }) => ( +
+

{row.original.name}

+

ID {row.original.id}

+
+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => , + }, + { + accessorKey: "openclaw_session_id", + header: "Session", + cell: ({ row }) => ( + + {truncate(row.original.openclaw_session_id)} + + ), + }, + { + accessorKey: "last_seen_at", + header: "Last seen", + cell: ({ row }) => ( +
+

+ {formatRelative(row.original.last_seen_at)} +

+

{formatTimestamp(row.original.last_seen_at)}

+
+ ), + }, + { + accessorKey: "updated_at", + header: "Updated", + cell: ({ row }) => ( + + {formatTimestamp(row.original.updated_at)} + + ), + }, + { + id: "actions", + header: "", + cell: ({ row }) => ( +
event.stopPropagation()} + > + + View + + + Edit + + +
+ ), + }, + ], + [] + ); + + const table = useReactTable({ + data: sortedAgents, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); return ( -
-

- Sign in to view operational status. -

+
+

Sign in to view agents.

-
-
-

- Operations -

-

Agents

+
+
+

Agents

- Live status and heartbeat activity across all agents. + {agents.length} agent{agents.length === 1 ? "" : "s"} total.

- -
@@ -326,282 +301,114 @@ export default function AgentsPage() {
) : null} -
+ {agents.length === 0 && !isLoading ? ( +
+ No agents yet. Create your first agent to get started. +
+ ) : (
-
-

- Agents -

-

- {sortedAgents.length} total -

-
-
- {sortedAgents.length === 0 && !isLoading ? ( -
- No agents yet. Add one or wait for a heartbeat. -
- ) : ( - sortedAgents.map((agent) => ( -
-
-

{agent.name}

-

- Last seen {formatRelative(agent.last_seen_at)} -

-
-
- - -
-
- )) - )} -
-
- -
- -
- - Activity - Gateway - -
- -
-

- Activity feed -

-

- {events.length} events -

-
-
- {events.length === 0 && !isLoading ? ( -
- No activity yet. -
- ) : ( - events.map((event) => ( -
-

- {event.message ?? event.event_type} -

-

- {formatTimestamp(event.created_at)} -

-
- )) - )} -
-
- -
-

- OpenClaw Gateway -

- -
-
-
-
-

- {gatewayStatus?.connected ? "Connected" : "Not connected"} -

- -
-

- {gatewayStatus?.gateway_url ?? "Gateway URL not set"} -

- {gatewayStatus?.error ? ( -

- {gatewayStatus.error} -

- ) : null} -
- -
-
- Sessions - {gatewaySessions.length} -
-
- {gatewaySessions.length === 0 ? ( -
- No sessions found. -
- ) : ( - gatewaySessions.map((session, index) => { - const sessionId = session.key as string | undefined; - const display = - (session.displayName as string | undefined) ?? - (session.label as string | undefined) ?? - sessionId ?? - "Session"; - return ( - - ); - }) - )} -
-
- - {selectedSession ? ( -
-
-

- Session details -

-

- {selectedSession.displayName ?? - selectedSession.label ?? - selectedSession.key ?? - "Session"} -

-
-
- {sessionHistory.length === 0 ? ( -

No history loaded.

- ) : ( - sessionHistory.map((item, index) => ( -
-                                {JSON.stringify(item, null, 2)}
-                              
- )) - )} -
-
- - setMessage(event.target.value)} - placeholder="Type a message to the session" - className="h-10" - /> -
+ )} + +
+
+
+

+ Gateway status +

+

+ {gatewayStatus?.gateway_url ?? "Gateway URL not set"} +

+
+
+ + + {gatewayStatus?.sessions_count ?? 0} sessions + +
+
+ {gatewayStatus?.error ? ( +

+ {gatewayStatus.error} +

+ ) : null} + {gatewayError ? ( +

+ {gatewayError} +

+ ) : null}
{ - setIsDialogOpen(nextOpen); if (!nextOpen) { - resetForm(); + setDeleteTarget(null); + setDeleteError(null); } }} > - + - New agent + Delete agent - Add a manual agent entry for tracking and monitoring. + This will remove {deleteTarget?.name}. This action cannot be undone. -
-
- - setName(event.target.value)} - placeholder="e.g. Deployment bot" - className="h-11" - /> + {deleteError ? ( +
+ {deleteError}
-
- - -
- {createError ? ( -
- {createError} -
- ) : null} -
+ ) : null} - - diff --git a/templates/HEARTBEAT.md b/templates/HEARTBEAT.md index ef3b71d2..3ddc656f 100644 --- a/templates/HEARTBEAT.md +++ b/templates/HEARTBEAT.md @@ -4,14 +4,14 @@ If this file is empty, skip heartbeat work. ## Required inputs - BASE_URL (e.g. http://localhost:8000) -- AUTH_TOKEN (Bearer token) +- AUTH_TOKEN (agent token) - AGENT_NAME ## On every heartbeat 1) Check in: ```bash curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \ - -H "Authorization: Bearer $AUTH_TOKEN" \ + -H "X-Agent-Token: $AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "'$AGENT_NAME'", "status": "online"}' ``` @@ -19,13 +19,13 @@ curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \ 2) List boards: ```bash curl -s "$BASE_URL/api/v1/boards" \ - -H "Authorization: Bearer $AUTH_TOKEN" + -H "X-Agent-Token: $AUTH_TOKEN" ``` 3) For each board, list tasks: ```bash curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks" \ - -H "Authorization: Bearer $AUTH_TOKEN" + -H "X-Agent-Token: $AUTH_TOKEN" ``` 4) Claim next task (FIFO): @@ -33,7 +33,7 @@ curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks" \ - Claim it by moving it to "in_progress": ```bash curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \ - -H "Authorization: Bearer $AUTH_TOKEN" \ + -H "X-Agent-Token: $AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "in_progress"}' ``` @@ -43,7 +43,7 @@ curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \ - When complete, move to "review": ```bash curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \ - -H "Authorization: Bearer $AUTH_TOKEN" \ + -H "X-Agent-Token: $AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "review"}' ``` diff --git a/templates/IDENTITY.md b/templates/IDENTITY.md index 8c3d93a6..2ff0d6bf 100644 --- a/templates/IDENTITY.md +++ b/templates/IDENTITY.md @@ -5,4 +5,3 @@ Agent ID: {{ agent_id }} Creature: AI Vibe: calm, precise, helpful Emoji: :gear: -Avatar: avatars/{{ agent_id }}.png