Grok Build 日常入口是全屏 TUI:不带参数执行 grok,打开能点鼠标的终端界面。脚本里发一条提示就结束,用 -p。要把同一套编码 agent 嵌进 IDE,或接到自己写的工具里,走第三条入口:Agent Client Protocol(ACP)。
对应命令是 grok agent stdio。它把 Grok Build 跑成一个 ACP agent,宿主应用在 stdin/stdout 上用 JSON-RPC 驱它,不必再包一层 TUI。
三种入口里 ACP 站在哪
三条路用的是同一套 agent,差在谁来交互。
TUI 给人盯着终端用。无头 -p 给脚本和机器人:发一条,跑完退出,stdout 留给你解析。ACP 给的是应用嵌入:编辑器、内部工具、自己写的宿主把 Grok Build 当 ACP agent 来驱,不再另开一次终端会话。
直接打 grok-4.6 的 xAI API 是另一条路。你已经有自己的 agent loop、IDE 插件或编码工具时,可以不经过 Grok Build 进程。ACP 做的是把进程本身接到应用里:会话、工具调用、落盘仍由 Grok Build 负责。
启动与鉴权
拉起 agent:
grok agent stdio
进程在 stdin/stdout 上讲 JSON-RPC。跑示例之前,本机需要已经登录过,或者环境里有 XAI_API_KEY。
客户端先发 initialize。返回值里有 authMethods,每项带一个 id。示例按这个顺序挑一种:环境变量里已经有 XAI_API_KEY,并且方法列表含 xai.api_key,就用 API Key;否则用本机缓存的 cached_token。两种都对不上,就停下来,提示先 grok login,或设置 XAI_API_KEY。
const authMethods = new Set((init.authMethods ?? []).map(method => method.id));
const methodId =
process.env.XAI_API_KEY && authMethods.has("xai.api_key")
? "xai.api_key"
: authMethods.has("cached_token")
? "cached_token"
: null;
if (!methodId) {
throw new Error("Run `grok login` first, or set XAI_API_KEY.");
}
await request("authenticate", { methodId, _meta: { headless: true } });
两边都有的时候,示例会走 xai.api_key,不会仅仅因为本机登录过就忽略环境变量里的 Key。authenticate 里的 _meta.headless: true 跟着示例写即可。
一次最小会话回路
鉴权之后按这个顺序发请求:initialize → authenticate → session/new → session/prompt。每条都是 JSON-RPC 2.0,一行一个 JSON,写进 stdin。
initialize 要带协议版本和客户端能力。示例里的版本是 1,并声明可以读写文本文件、使用终端:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": 1,
"clientCapabilities": {
"fs": { "readTextFile": true, "writeTextFile": true },
"terminal": true
}
}
}
session/new 给出工作目录。返回值里的 sessionId 后面每一步都要用:
const { sessionId } = await request("session/new", {
cwd: process.cwd(),
mcpServers: [],
});
然后把用户话放进 session/prompt。prompt 是数组,文本块的形状是 { type: "text", text: "..." }:
const prompt = await request("session/prompt", {
sessionId,
prompt: [{ type: "text", text: "Say hello in one short sentence." }],
});
session/prompt 的返回值是完成元数据,示例会读 stopReason。助手正文不在这个 result 里,而是作为通知推过来。
通知的方法名是 session/update。当 update.sessionUpdate 等于 agent_message_chunk 时,片段在 update.content.text。客户端按行读 stdout,把这些 chunk 拼起来:
if (message.method === "session/update") {
const update = message.params?.update;
if (update?.sessionUpdate === "agent_message_chunk" && update.content?.text) {
text += update.content.text;
}
return;
}
stdout 上请求应答和 session/update 会混在一起。示例先看 message.method === "session/update",命中就当通知处理,再拿 message.id 去对还没完成的请求。宿主要两边都收。
下面这份示例拉起进程、走完最小回路。超时、以及 session/prompt 返回后再等文本长度连续两次不变,属于示例客户端自己的等待逻辑,不是协议字段。
import { spawn } from "node:child_process";
import readline from "node:readline";
import process from "node:process";
const proc = spawn("grok", ["agent", "stdio"], { stdio: ["pipe", "pipe", "pipe"] });
const rl = readline.createInterface({ input: proc.stdout });
const pending = new Map();
let nextId = 1;
let text = "";
proc.stderr.on("data", chunk => process.stderr.write(chunk));
rl.on("line", line => {
const message = JSON.parse(line);
if (message.method === "session/update") {
const update = message.params?.update;
if (update?.sessionUpdate === "agent_message_chunk" && update.content?.text) {
text += update.content.text;
}
return;
}
const pendingRequest = pending.get(message.id);
if (!pendingRequest) return;
pending.delete(message.id);
if (message.error) {
pendingRequest.reject(new Error(message.error.message ?? JSON.stringify(message.error)));
} else {
pendingRequest.resolve(message.result ?? {});
}
});
function request(method, params, timeoutMs = 30000) {
const id = nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`${method} timed out`));
}, timeoutMs);
pending.set(id, {
resolve(result) {
clearTimeout(timer);
resolve(result);
},
reject(error) {
clearTimeout(timer);
reject(error);
},
});
proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
});
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
try {
const init = await request("initialize", {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
terminal: true,
},
});
const authMethods = new Set((init.authMethods ?? []).map(method => method.id));
const methodId =
process.env.XAI_API_KEY && authMethods.has("xai.api_key")
? "xai.api_key"
: authMethods.has("cached_token")
? "cached_token"
: null;
if (!methodId) {
throw new Error("Run `grok login` first, or set XAI_API_KEY.");
}
await request("authenticate", { methodId, _meta: { headless: true } });
const { sessionId } = await request("session/new", {
cwd: process.cwd(),
mcpServers: [],
});
const prompt = await request("session/prompt", {
sessionId,
prompt: [{ type: "text", text: "Say hello in one short sentence." }],
});
let lastLength = -1;
let stableChecks = 0;
while (stableChecks < 2) {
await sleep(150);
if (text.length === lastLength) {
stableChecks += 1;
} else {
lastLength = text.length;
stableChecks = 0;
}
}
console.log(text.trim() || `No text returned (stopReason=${prompt.stopReason})`);
} finally {
rl.close();
proc.kill();
}
会话与 TUI 是同一套
Grok Build 会自动把每次对话写到磁盘:提示、回复、工具调用、文件快照,按工作目录归档在 ~/.grok/sessions/。TUI、无头、ACP 用的是同一套落盘。
session/new 返回的 sessionId 就是这条会话的 ID。关掉宿主之后,可以在终端里续:
grok --resume <session-id>
grok --resume
grok -c
带 ID 的 --resume 恢复指定会话。不带 ID 的 --resume 恢复当前目录最近一次;-c 是同一件事的简写。TUI 里用 /resume 打开当前工作区的近期会话列表,欢迎屏也会列出这些会话。
无头侧若要从 JSON 里取出 ID 再续跑,和 ACP 拿到的是同一类标识:
grok -p "Start the refactor" --output-format json | jq -r '.sessionId'
IDE 里开过的对话,只要还拿着那个 sessionId、还在同一个工作目录,回到终端 grok --resume 就能接着聊。