diff --git a/CHANGELOG.md b/CHANGELOG.md
index d696c45..4bf65cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## 0.14.0 — 2026-07-22
+
+### Added
+
+- **Tool results now reach consumers.** The streaming `chat()` generator now emits a `tool_result` chunk for the current Gateway's tool-result event shape (`stream:"tool", phase:"result"`, with the tool in `name` and the payload in `result`). Previously the tool handler only recognized the older `phase:"end"`/`output` shape — which the current Gateway does not send — so `tool_result` chunks were never produced and all tool output was invisible to consumers. Both shapes are now supported, and the tool name is read from `name` (falling back to the legacy `tool` field). The chunk text is the tool's actual text output (the `content[].text` items of `toTranscriptToolResult` joined with newlines), preserving literal quotes so structured markers a plugin writes into its output survive to the consumer. Exposes a `toolResultText(payload)` helper for the same normalization. (heypinchy/pinchy#703)
+
## 0.13.1 — 2026-07-02
### Fixed
diff --git a/package.json b/package.json
index ba5af37..f4d3584 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openclaw-node",
- "version": "0.13.1",
+ "version": "0.14.0",
"description": "Node.js client for the OpenClaw Gateway WebSocket protocol",
"main": "dist/index.js",
"module": "dist/index.mjs",
diff --git a/src/__tests__/chat.test.ts b/src/__tests__/chat.test.ts
index 71f952c..418b750 100644
--- a/src/__tests__/chat.test.ts
+++ b/src/__tests__/chat.test.ts
@@ -132,6 +132,71 @@ describe("Chat streaming", () => {
expect(chunks).toEqual(["Hello", " world", "!"]);
});
+ it("emits a tool_result chunk from a gateway phase:'result' tool event", async () => {
+ // The Gateway emits tool results as `stream:"tool", phase:"result"` with a
+ // `name` field and a `result` object (`toTranscriptToolResult`, whose
+ // `content[].text` carries the tool's text output). Older releases used
+ // `phase:"end"`/`output`; we must handle BOTH so a consumer (e.g. Pinchy's
+ // agent→user file delivery) actually sees tool output — otherwise the tool
+ // result never reaches the web layer at all.
+ const ws = getMockWs();
+ const sentBefore = ws.sent.length;
+
+ const chunks: ChatChunk[] = [];
+ const gen = client.chat("download the invoice");
+ const consumePromise = (async () => {
+ for await (const chunk of gen) chunks.push(chunk);
+ })();
+
+ await new Promise((r) => setTimeout(r, 0));
+ const sentMsg = JSON.parse(ws.sent[sentBefore]);
+ const requestId = sentMsg.id;
+
+ ws.simulateMessage({
+ type: "res",
+ id: requestId,
+ ok: true,
+ payload: { runId: requestId, status: "accepted" },
+ });
+
+ ws.simulateMessage({
+ type: "event",
+ event: "agent",
+ payload: {
+ runId: requestId,
+ stream: "tool",
+ data: {
+ phase: "result",
+ name: "email_get_attachment",
+ result: {
+ content: [
+ {
+ type: "text",
+ text: 'saved ',
+ },
+ ],
+ },
+ },
+ },
+ });
+
+ ws.simulateMessage({
+ type: "res",
+ id: requestId,
+ ok: true,
+ payload: { runId: requestId, status: "ok", result: { payloads: [] } },
+ });
+
+ await consumePromise;
+
+ const toolResult = chunks.find((c) => c.type === "tool_result");
+ expect(toolResult).toBeDefined();
+ // Carries the tool name and the full serialized result, so a marker in the
+ // tool's text output survives to the consumer.
+ expect(toolResult!.text).toContain("email_get_attachment");
+ expect(toolResult!.text).toContain(' {
const ws = getMockWs();
const sentBefore = ws.sent.length;
diff --git a/src/__tests__/tool-result-text.test.ts b/src/__tests__/tool-result-text.test.ts
new file mode 100644
index 0000000..620e7ed
--- /dev/null
+++ b/src/__tests__/tool-result-text.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from "vitest";
+import { toolResultText } from "../client";
+
+describe("toolResultText", () => {
+ it("returns a string payload as-is", () => {
+ expect(toolResultText("plain output")).toBe("plain output");
+ });
+
+ it("joins the text items of a toTranscriptToolResult content array (literal quotes preserved)", () => {
+ const payload = {
+ content: [
+ {
+ type: "text",
+ text: 'saved ',
+ },
+ ],
+ };
+ expect(toolResultText(payload)).toBe(
+ 'saved ',
+ );
+ });
+
+ it("joins multiple text items with newlines and ignores non-text items", () => {
+ const payload = {
+ content: [
+ { type: "text", text: "line 1" },
+ { type: "image", url: "http://example/img.png" },
+ { type: "text", text: "line 2" },
+ ],
+ };
+ expect(toolResultText(payload)).toBe("line 1\nline 2");
+ });
+
+ it("falls back to JSON for an object without a usable content array", () => {
+ expect(toolResultText({ foo: "bar" })).toBe('{"foo":"bar"}');
+ });
+
+ it("stringifies null/undefined to an empty JSON string", () => {
+ expect(toolResultText(undefined)).toBe('""');
+ expect(toolResultText(null)).toBe('""');
+ });
+});
diff --git a/src/client.ts b/src/client.ts
index 24c09d7..b63fb99 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -147,6 +147,36 @@ export type ChatChunk =
runId: string;
};
+/**
+ * Serialize a tool-result payload into the text carried by a `tool_result`
+ * chunk. The Gateway's `phase:"result"` payload is a `toTranscriptToolResult`
+ * object whose `content[]` holds the tool's output items; we join the text
+ * items so the consumer sees the tool's ACTUAL text output (with literal
+ * quotes), not a JSON-escaped envelope. Falls back to the raw string, or a
+ * JSON dump for shapes we don't recognize.
+ */
+export function toolResultText(payload: unknown): string {
+ if (typeof payload === "string") return payload;
+ if (
+ payload &&
+ typeof payload === "object" &&
+ Array.isArray((payload as { content?: unknown }).content)
+ ) {
+ const items = (payload as { content: unknown[] }).content;
+ const texts = items
+ .filter(
+ (c): c is { type: "text"; text: string } =>
+ !!c &&
+ typeof c === "object" &&
+ (c as { type?: unknown }).type === "text" &&
+ typeof (c as { text?: unknown }).text === "string",
+ )
+ .map((c) => c.text);
+ if (texts.length > 0) return texts.join("\n");
+ }
+ return JSON.stringify(payload ?? "");
+}
+
const PROTOCOL_VERSION = 4;
const DEFAULT_SCOPES = ["operator.read", "operator.write"];
@@ -515,14 +545,22 @@ export class OpenClawClient extends EventEmitter {
}
} else if (stream === "tool") {
const phase = data?.phase as string | undefined;
- const toolName = (data?.tool as string) ?? "unknown";
+ // The Gateway names the tool in `name`; older releases used `tool`.
+ const toolName = (data?.tool as string) ?? (data?.name as string) ?? "unknown";
if (phase === "start") {
chunks.push({ type: "tool_use", text: toolName, runId });
resolveChunk?.();
- } else if (phase === "end") {
- const output = data?.output;
- const outputText = typeof output === "string" ? output : JSON.stringify(output ?? "");
+ } else if (phase === "end" || phase === "result") {
+ // A tool result. The current Gateway emits `phase:"result"` with a
+ // `result` object (`toTranscriptToolResult`, whose `content[].text`
+ // holds the tool's text output); older releases emitted
+ // `phase:"end"` with `output`. Support BOTH — without the `result`
+ // case the tool result never reaches consumers at all (the
+ // `end`/`output` shape is not what the current Gateway sends), so
+ // e.g. a marker a plugin writes into its output would be lost.
+ const payload = phase === "result" ? data?.result : data?.output;
+ const outputText = toolResultText(payload);
chunks.push({ type: "tool_result", text: `${toolName}: ${outputText}`, runId });
// A completed tool call ends the current assistant turn. Only
// emit the turn-boundary marker if we've actually seen