How Tool‑Call Field Normalization Works Between Pi‑Web Session Files and `ToolCallContent` Types

Tool‑call field normalization in Pi‑Web converts raw Pi SDK session data into the application's internal ToolCallContent type via the normalizeToolCalls function in lib/normalize.ts, mapping id to toolCallId, name to toolName, and parsing the JSON arguments string into input.

The Pi‑Web repository (agegr/pi-web) provides a web interface for reviewing Pi SDK agent sessions. Session files persist tool invocations in Pi's native format, but the React‑based UI expects a normalized TypeScript structure. The normalizeToolCalls function bridges this gap, ensuring consistent data handling across file reads and streaming events.


What Pi‑Web Normalizes: Two Different Schemas

Pi SDK session files (.jsonl) store tool calls with Pi's original field names and string‑encoded arguments:

{
  "type": "toolCall",
  "id": "<uuid>",
  "name": "<tool-name>",
  "arguments": "<json-string>"
}

The Pi‑Web application, however, works with the ToolCallContent type defined in lib/pi-types.ts:

export type ToolCallContent = {
  toolCallId: string;
  toolName: string;
  input: unknown;
};

Field differences requiring normalization:

  • id → toolCallId (renamed for clarity)
  • name → toolName (prefixed with "tool" for disambiguation)
  • arguments (JSON string) → input (parsed JSON value)

The normalizeToolCalls Function in lib/normalize.ts

The core transformation logic resides in lib/normalize.ts at line 57. The function is deliberately small, pure, and safe to call repeatedly.

How normalizeToolCalls works

  1. Filter to relevant messages — Only processes messages where role === "assistant" and toolCalls exists. All other messages pass through unchanged.

  2. Map and transform each tool call — Iterates msg.toolCalls, building new ToolCallContent objects with renamed fields and parsed JSON.

  3. Assign transformed array — Replaces msg.toolCalls with the normalized ToolCallContent[] array.

  4. Return updated message — Returns the mutated message for downstream consumption.

Implementation excerpt from lib/normalize.ts:

export function normalizeToolCalls(msg: AgentMessage): AgentMessage {
  if (msg.role !== "assistant" || !msg.toolCalls) {
    return msg;
  }

  const normalized: ToolCallContent[] = msg.toolCalls.map(tc => ({
    toolCallId: tc.id,
    toolName: tc.name,
    input: JSON.parse(tc.arguments),
  }));

  msg.toolCalls = normalized;
  return msg;
}

Where Normalization Is Applied

Pi‑Web calls normalizeToolCalls at critical boundaries between external data and internal state.

Session file reading: lib/session-reader.ts

When loading historical sessions from disk, lib/session-reader.ts (lines 332‑333) applies normalization immediately after parsing each entry. This ensures archived sessions conform to current UI expectations regardless of when they were created.

Streaming event handling: hooks/useAgentSession.ts

Real‑time tool calls arrive via Server‑Sent Events (SSE). hooks/useAgentSession.ts normalizes incoming payloads at lines 1158 and 1172 before updating React state. This prevents malformed or outdated schemas from reaching UI components.

UI component consumption

Components like MessageView.tsx receive pre‑normalized messages. They reference toolCallId, toolName, and input directly without defensive parsing or field mapping.


Why Field Normalization Matters

Decoupling from Pi SDK evolution — The Pi SDK may change its storage format, but Pi‑Web's transformation layer insulates the UI from upstream changes.

TypeScript type safety — The ToolCallContent type in lib/pi-types.ts provides compile‑time guarantees. Normalization ensures these guarantees hold for all data sources.

Single JSON parse per payload — Converting arguments → input once at the boundary eliminates repeated JSON.parse calls in rendering logic, reducing error surfaces and improving performance.


Working with the Normalizer

Use normalizeToolCalls directly when importing raw session data from external sources or testing:

import { normalizeToolCalls } from '@/lib/normalize';
import type { AgentMessage } from '@/lib/pi-types';

async function loadSessionEntry(entryPath: string): Promise<AgentMessage> {
  const raw = await fetch(entryPath).then(r => r.json());
  return normalizeToolCalls(raw as AgentMessage);
}

// Example: accessing normalized tool call data
const msg = await loadSessionEntry('/path/to/session/entry.jsonl');
console.log(msg.toolCalls?.[0].toolName);   // "weather"
console.log(msg.toolCalls?.[0].input);      // { location: "Berlin" }

Summary

  • Raw Pi format uses id, name, and string arguments; Pi‑Web format uses toolCallId, toolName, and parsed input.
  • normalizeToolCalls in lib/normalize.ts performs a pure, idempotent transformation between these schemas.
  • Normalization occurs at data boundaries: lib/session-reader.ts for files, hooks/useAgentSession.ts for streaming events.
  • The pattern provides type safety, schema isolation, and performance by parsing JSON once at ingestion.

Frequently Asked Questions

What triggers tool‑call normalization in Pi‑Web?

Normalization runs automatically when session data enters the application—either from .jsonl files via lib/session-reader.ts or from SSE streams via hooks/useAgentSession.ts. No manual invocation is required during normal operation.

Why does Pi‑Web rename id to toolCallId and name to toolName?

The prefixes clarify purpose and prevent naming collisions. Messages contain multiple identifiers; toolCallId explicitly scopes the ID to the tool invocation, while toolName distinguishes the tool identifier from other names in the message context.

What happens if arguments contains invalid JSON?

normalizeToolCalls calls JSON.parse(tc.arguments) directly. Invalid JSON will throw a SyntaxError at normalization time, causing the message to fail processing. This fail‑fast behavior prevents malformed data from propagating to UI components.

Can normalizeToolCalls be called multiple times on the same message?

Yes. The function is idempotent for already‑normalized messages because it checks msg.role !== "assistant" || !msg.toolCalls first. Once normalized, toolCalls contains ToolCallContent objects without an arguments property, so subsequent calls return the message unchanged.

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 →