OpenCode、OpenClaw、Hermes对外APIs接口
目前流行的AI Agent,无非就是Claude Code、Codex、OpenCode、OpenClaw、Hermes,以及国内的WorkBuddy、QwenPaw、QoderWork、MimoCode等。
对于如下的一个场景:你在一台机器上,部署了一个Agent,并配置了相应的Skills、mcp等,但是想在另一台机器上,调用这个Agent,就有些难了。
-
有些只是单纯的本地工具,无法通过远程调用,没有对外的APIs接口,例如Claude Code、WorkBuddy等。
-
有些因为高风险,导致公司要求只能部署在单独的服务器,例如OpenClaw、Hermes等,更需要对外的APIs接口。
本文只是简单描述了OpenCode、OpenClaw、Hermes的对外APIs,以及使用方法,不涉及A2A相关。
01
—
OpenCode
OpenCode是一个GitHub开源的项目,自带对外的APIs接口,可通过opencode serve启动,最好是先进入到你的项目,再启动,这样就会自动加载项目中的配置、skill等。

详细文档,可参考:
https://opencode.ai/docs/zh-cn/server
可设置用户名、密码、端口等,增强链接的安全性。
对于基于OpenCode开发的mimo code、mobile code等,是否支持APIs,需要自行尝试。
1、简单用法就两步:
-
发送POST /session接口,创建一个新的会话。获取session id。
-
发送POST /session/:id/message接口,发送消息并等待响应。
-
请求体:{ messageID?, model?, agent?, noReply?, system?, tools?, parts }
-
返回:{ info: Message, parts: Part[]}
这里需要注意的是parts部分,他不是常规的openai的/v1/chat/completions或者/v1/responses格式。
最简单的就是{“parts”: [{“type”: “text”, “text”: “hello”}]},这里的type,可以换成多种方式reasoning、file、tool等等。
要想知道parts里具体都有啥,以及返回信息,这边有个简单的脚本,可以让你看看自己的会话中触发了哪些:
def jiexi(response):print(response.text)data = response.json() if isinstance(response.json(), list) else [response.json()]for r in data:role = r['info']['role']if role == 'user':print(f"{role}:{r['parts'][0]['text']}")elif role == 'assistant':for p in r['parts']:if p["type"] == "reasoning":#思考passelif p["type"] == "text":print(f"{role}:{p['text']}")res1 = requests.get(url='http://127.0.0.1:4096/session/ses_idxxxxxx/message',)#{"name":"NotFoundError","data":{"message":"Session not found: ses_116935062ffejURD7xVWvGDax"}}if "Session not found" in res1.text:pass# 需要重新创建会话else:jiexi(res1)
2、加个中间层fastapi
为啥要加这个,因为我希望,他对同一用户,使用同一session。在这一层中,加了用户和session的映射关系。OpenCode对于同一session,会自动发送上下文给大模型,免去我们手动保存添加的步骤。
1、用户发来相关问题{phone:xxxxxx, query:xxxxxx}。
2、中间层根据phone,获取对应的session id,若无,则创建新session,同时添加系统提示词。
3、中间层转发query给OpenCode。
4、OpenCode根据session里的上下文、skill、mcp等信息,回答用户。
下面是一段示例代码,实现了基础的功能,但有一些问题:
1、相关配置,没有单独提取出来;
2、用的是字典的方式保存映射关系,可以让AI修改成redis等来保存。
import asynciofrom contextlib import asynccontextmanagerfrom typing import Optionalimport httpxfrom fastapi import FastAPI, HTTPExceptionfrom loguru import loggerfrom pydantic import BaseModel# ==================== 日志配置 ====================# 移除默认 handler,避免重复打印logger.remove()# 1. 控制台输出:带颜色,仅显示 INFO 及以上logger.add(lambda msg: print(msg, end=""), # 兼容 Windows 控制台颜色level="INFO",format="<green>{time:HH:mm:ss}</green> | <level>{level:<7}</level> | {message}",colorize=True)# 2. 文件输出:按天切割,保留30天,记录完整内容logger.add("logs/opencode_{time:YYYY-MM-DD}.log",rotation="00:00", # 每天零点切割retention="30 days", # 保留30天encoding="utf-8",level="DEBUG",format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<7} | {message}",enqueue=True # 🔑 异步写入,不阻塞 FastAPI 事件循环)# ==================== 数据模型 ====================class ChatRequest(BaseModel):phone: strquery: strclass ChatResponse(BaseModel):phone: strsession_id: strreply: str# ==================== OpenCode 客户端 ====================class OpenCodeClient:# 🔧 在此处定义系统提示词,方便后续维护SYSTEM_PROMPT = ("你是一个专业的智能助手。请始终使用中文回答,""保持语气友好、专业,并在回答前仔细分析用户的问题。")def __init__(self, base_url: str = "http://127.0.0.1:4096"):self.base_url = base_url.rstrip("/")self.phone_session_map: dict[str, str] = {}self._lock = asyncio.Lock()self._client: Optional[httpx.AsyncClient] = Noneasync def start(self):self._client = httpx.AsyncClient(timeout=60*10)logger.info("OpenCode HTTP 客户端已初始化")async def stop(self):if self._client:await self._client.aclose()logger.info("OpenCode HTTP 客户端已关闭")async def _create_session(self, phone: str) -> str:# 1. 创建新会话res = await self._client.post(f"{self.base_url}/session")res.raise_for_status()session_id = res.json()["id"]logger.info(f"[新建会话] phone={phone} | session_id={session_id}")# 🔑 2. 首次创建时,立即注入系统提示词sys_payload = {"parts": [{"type": "text", "text": self.SYSTEM_PROMPT}]}sys_res = await self._client.post(f"{self.base_url}/session/{session_id}/message",json=sys_payload,headers={'Content-Type': 'application/json'})if sys_res.status_code == 200:logger.debug(f"[系统提示词注入成功] phone={phone} | session={session_id}")else:logger.warning(f"[系统提示词注入失败] phone={phone} | session={session_id} | "f"status={sys_res.status_code} | body={sys_res.text}")return session_idasync def _session_exists(self, session_id: str) -> bool:res = await self._client.get(f"{self.base_url}/session/{session_id}/message")return "Session not found" not in res.textasync def get_or_create_session(self, phone: str) -> str:async with self._lock:session_id = self.phone_session_map.get(phone)if session_id:if await self._session_exists(session_id):logger.debug(f"[复用会话] phone={phone} | session_id={session_id}")return session_idelse:logger.warning(f"[会话失效] phone={phone} | old_session={session_id}")del self.phone_session_map[phone]new_session_id = await self._create_session(phone)self.phone_session_map[phone] = new_session_idreturn new_session_id@staticmethoddef _extract_reply(data) -> str:items = data if isinstance(data, list) else [data]texts = []for r in items:if r.get("info", {}).get("role") == "assistant":for p in r.get("parts", []):if p.get("type") == "text":texts.append(p["text"])return "\n".join(texts)async def chat(self, phone: str, query: str) -> dict:# 📝 记录用户发送内容logger.info(f"[用户提问] phone={phone} | query={query!r}")session_id = await self.get_or_create_session(phone)payload = {"parts": [{"type": "text", "text": query}]}res = await self._client.post(f"{self.base_url}/session/{session_id}/message",json=payload,)# 极端情况重试if "Session not found" in res.text:logger.warning(f"[会话异常重试] phone={phone} | session={session_id}")async with self._lock:self.phone_session_map.pop(phone, None)session_id = await self.get_or_create_session(phone)res = await self._client.post(f"{self.base_url}/session/{session_id}/message",json=payload,)reply = self._extract_reply(res.json())# 📝 记录 AI 回复内容logger.info(f"[AI回复] phone={phone} | session={session_id} | reply={reply!r}")return {"session_id": session_id, "reply": reply}# ==================== FastAPI 应用 ====================opencode = OpenCodeClient()@asynccontextmanagerasync def lifespan(app: FastAPI):await opencode.start()yieldawait opencode.stop()app = FastAPI(title="OpenCode Proxy", lifespan=lifespan)@app.post("/chat", response_model=ChatResponse)async def chat(req: ChatRequest):try:result = await opencode.chat(phone=req.phone, query=req.query)except httpx.HTTPStatusError as e:logger.error(f"[OpenCode错误] phone={req.phone} | status={e.response.status_code} | detail={e.response.text}")raise HTTPException(status_code=e.response.status_code, detail=f"OpenCode 请求失败: {e.response.text}")except Exception as e:logger.exception(f"[未知异常] phone={req.phone} | error={e}")raise HTTPException(status_code=500, detail=str(e))return ChatResponse(phone=req.phone,session_id=result["session_id"],reply=result["reply"],)
02
—
OpenClaw
OpenClaw有自带的对外APIs接口。遵循OpenAI的两种规范。当Gateway启动后,自动就有。
-
/v1/chat/completions:
https://docs.openclaw.ai/gateway/openai-http-api
-
/v1/responses:
https://docs.openclaw.ai/gateway/openresponses-http-api
至于是从openclaw哪个版本开始支持的,这个就不清楚了。
根据Session behavior这块的文档,接口有3种行为:
-
默认情况下,每个请求都是无状态的,每次都会生成一个新的session key。
-
如果请求里包含user字段,Gateway会根据这个user派生出一个稳定的session key,因此相同的user会命中同一个agent session。
-
如果显式指定x-openclaw-session-key,则直接使用你指定的session 进行路由(explicit session routing)。
OpenClaw官网的右下角,有个Molty机器人,可以和他对话,他会告知你整个文档中的所有内容。

1、默认情况
Gateway为这次请求创建一个新的session。下一次发送同样的请求,也会进入另一个新的session。两次请求之间不会共享聊天历史。
curl http://localhost:8080/v1/responses \-H "Authorization: Bearer YOUR_TOKEN" \-H "Content-Type: application/json" \-d '{"model": "openclaw","input": "你好"}'
Authorization:看配置文件里的gateway.auth.mode配置的是啥。
-
“token” or “password”:token是默认且常见的,Bearer <token-or-password>
-
“trusted-proxy”:一般不常用这个。
-
“none”:不需要该字段。
model:”openclaw”,路由到配置好的默认agent。
2、指定user
对于同一个user,每次使用同一个会话。对于不同的user,则会命中另一个对应的session。两个人的上下文互不影响。
curl http://localhost:8080/v1/responses \-H "Authorization: Bearer YOUR_TOKEN" \-H "Content-Type: application/json" \-d '{"model": "openclaw","user": "alice","input": "你好"}'
3、指定x-openclaw-session-key
这种方式完全由调用方决定要进入哪个session。这种方式适合你的系统已经有自己的会话ID,希望OpenClaw直接使用该标识进行路由。
curl http://localhost:8080/v1/responses \-H "Authorization: Bearer YOUR_TOKEN" \-H "x-openclaw-session-key: customer-9527" \-H "Content-Type: application/json" \-d '{"model": "openclaw","input": "你好"}'
session-key怎么来?还记得web版的页面吗,在聊天框的上方,有会话id。
需要注意的是,不得使用保留的内部会话命名空间,如subagent:xxx、cron:xxx、acp:xxx,这些请求被拒绝,400 invalid_request_error。
和第一部分的OpenCode比起来,OpenCLaw的这种APIs接口,算是很简单的了,也不需要中间层作为转发。而且可自由选定,每个用户是独立的session,还是共用同一个。
03
—
Hermes
Hermes有自带的对外APIs接口,兼容OpenAI的HTTP端点。和OpenClaw一样,都支持/v1/chat/completions和/v1/responses。
https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server
1、启用API服务器
修改~/.hermes/.env文件
API_SERVER_ENABLED=trueAPI_SERVER_KEY=change-me-local-dev# Optional: only if a browser must call Hermes directly# API_SERVER_CORS_ORIGINS=http://localhost:3000
2、启动网关
hermes gateway
你将看到:
[API Server] API server listening on http://127.0.0.1:8642
3、连接
指向任何兼容 OpenAI 的客户端http://localhost:8642/v1:
# Test with curlcurl http://localhost:8642/v1/chat/completions \-H "Authorization: Bearer change-me-local-dev" \-H "Content-Type: application/json" \-d '{"model": "hermes-agent", "messages": [{"role": "user", "content": "Hello!"}]}'
-
/v1/chat/completions
-
/v1/responses
-
/v1/runs
流媒体友好型替代方案,服务器为长形式会话提供了run API,客户端希望订阅进度事件,而不是自己管理流媒体。
======END=======
夜雨聆风