How `normalizeToolCalls` Handles Field Name Mismatches in Pi Web: A Deep Dive into the Pi-to-UI Translation Layer

normalizeToolCalls is a utility function in lib/normalize.ts that automatically maps legacy Pi SDK field names (id, name, arguments) to the Pi-Web UI's expected fields (toolCallId, toolName, input), ensuring tool-call payloads render correctly regardless of their source format.

The Pi backend and the Pi-Web UI don't always agree on field names. When the Pi SDK emits a tool-call block, it uses one vocabulary; the TypeScript types in the web interface expect another. This field name mismatch would break tool rendering and downstream logic if left unhandled. The normalizeToolCalls function bridges this gap, acting as a single source of truth for converting raw Pi messages into UI-ready shapes.

Why Field Name Translation Is Necessary

The Pi SDK serializes tool calls using this structure:

{
  "type": "toolCall",
  "id": "<uuid>",
  "name": "<tool>",
  "arguments": { ... }
}

However, the Pi-Web UI's ToolCallContent type expects:

{
  type: "toolCall";
  toolCallId: string;
  toolName: string;
  input: Record<string, unknown>;
}

Without normalization, the UI treats incoming tool-call blocks as opaque objects. Tool results fail to render, and any component depending on the normalized shape—such as execution tracers or input inspectors—receives incomplete data.

How normalizeToolCalls Resolves Field Name Mismatches

Located in lib/normalize.ts, the function applies a scoped, block-level transformation that preserves non-tool content while intelligently mapping legacy fields.

Scope Filtering by Message Role

The function only processes messages where msg.role === "assistant". All other roles—including user, toolResult, and bashExecution—pass through unchanged. This prevents accidental mutation of payloads that never contain tool-call blocks.

Block-Level Conversion via normalizeToolCallBlock

For each element in an assistant message's content array, normalizeToolCalls delegates to normalizeToolCallBlock. This inner function performs the actual field mapping:

  • toolCallId – Uses block.toolCallId if present; otherwise falls back to block.id
  • toolName – Uses block.toolName if present; otherwise falls back to block.name
  • input – Uses block.input when it is an object; otherwise falls back to block.arguments. If neither is an object, returns {}

Graceful Handling of Non-Tool Blocks

If block.type !== "toolCall", the original block is returned untouched. This ensures text blocks, image blocks, and future block types survive normalization without data loss.

Output: A New AgentMessage

The function returns a shallow copy of the input message with a transformed content array. Original tool-call blocks are replaced with normalized versions; all other elements maintain their position and identity.

Where normalizeToolCalls Is Used in Pi Web

Three critical locations depend on this utility, guaranteeing consistent behavior across data ingestion paths.

Session Reading in lib/session-reader.ts

When converting persisted session entries to UI messages, the normalizer runs first. Lines 306–308 apply it unconditionally, with an optional wrapper to strip base64 images:

// lib/session-reader.ts (excerpt)
const message = options.deferToolResultImages
  ? omitToolResultBase64Images(normalizeToolCalls(entry.message))
  : normalizeToolCalls(entry.message);

SSE Stream Handling in hooks/useAgentSession.ts

Incoming server-sent events are normalized before React state updates. The hook invokes normalizeToolCalls at lines 973, 989, and 1003:

// hooks/useAgentSession.ts (excerpt)
dispatch({ type: "update", message: normalizeToolCalls(msg as AgentMessage) });

Direct Utility Import

Components and utilities can import and call the function directly for ad-hoc normalization needs.

Code Examples

Normalizing a Raw Assistant Message

import { normalizeToolCalls } from "@/lib/normalize";

const rawMsg: AssistantMessage = {
  role: "assistant",
  content: [
    { type: "toolCall", id: "42", name: "search", arguments: { query: "foo" } },
    { type: "text", text: "Here are the results." }
  ]
};

const normalized = normalizeToolCalls(rawMsg);
// normalized.content[0] now contains:
// {
//   type: "toolCall",
//   toolCallId: "42",
//   toolName: "search",
//   input: { query: "foo" }
// }

Key Source Files

File Purpose
lib/normalize.ts Implements normalizeToolCalls and normalizeToolCallBlock
lib/session-reader.ts Applies normalization when reading session entries
hooks/useAgentSession.ts Normalizes SSE payloads before state dispatch

Summary

  • normalizeToolCalls lives in lib/normalize.ts and serves as the canonical translator between Pi SDK and Pi-Web UI field names
  • Only assistant messages are transformed, with role-based guarding preventing side effects on other message types
  • Triple fallback strategy for toolCallId, toolName, and input ensures compatibility with both legacy and modern payload shapes
  • Three integration points—session reader, React hook, and direct import—share one implementation, eliminating drift
  • Non-tool blocks pass through unchanged, preserving forward compatibility with new content types

Frequently Asked Questions

What happens if a tool-call block has both id and toolCallId?

The normalizeToolCallBlock function prioritizes toolCallId when present, falling back to id only when the modern field is absent. This allows gradual migration: new code can emit toolCallId immediately, while legacy paths continue working with id.

Does normalizeToolCalls modify the original message object?

No. The function returns a new AgentMessage with a new content array. The original message and its blocks remain unmodified, supporting immutable update patterns in React and other frameworks.

Why not update the Pi SDK to match the UI types directly?

The normalization layer decouples deployment schedules. The Pi backend and Pi-Web UI can ship independently; normalizeToolCalls absorbs the impedance mismatch without requiring synchronized releases across services.

What fields are considered for the input mapping?

normalizeToolCallBlock checks block.input first. If that field exists and is an object, it is used. Otherwise, it falls back to block.arguments. If neither is an object, it returns {}, ensuring input always satisfies the Record<string, unknown> type constraint.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →