Skip to main content
Glama

Read Transcript

graph_read_transcript
Read-only

Read Claude Code JSONL transcripts and output normalized text messages, ensuring compatibility with future format updates.

Instructions

Read and parse a Claude Code JSONL transcript file through the canonical transcript parser. Returns normalized messages with text content extracted. Use this instead of reading raw JSONL directly — if the transcript format changes, only this tool needs updating.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
session_idNoSession UUID (filename without .jsonl). Searches ~/.claude/projects/ for a match.
file_pathNoAbsolute path to the .jsonl file. Takes precedence over session_id.
text_onlyNoIf true (default), return only messages that have extractable text content.

Implementation Reference

  • Tool 'graph_read_transcript' is registered with the MCP server, with inputSchema defining session_id, file_path, and text_only parameters.
    // ─── Tool: graph_read_transcript ───
    
    server.registerTool("graph_read_transcript", {
      title: "Read Transcript",
      description:
        "Read and parse a Claude Code JSONL transcript file through the canonical transcript parser. " +
        "Returns normalized messages with text content extracted. Use this instead of reading raw JSONL " +
        "directly — if the transcript format changes, only this tool needs updating.",
      inputSchema: {
        session_id: z
          .string()
          .optional()
          .describe("Session UUID (filename without .jsonl). Searches ~/.claude/projects/ for a match."),
        file_path: z
          .string()
          .optional()
          .describe("Absolute path to the .jsonl file. Takes precedence over session_id."),
        text_only: z
          .boolean()
          .optional()
          .default(true)
          .describe("If true (default), return only messages that have extractable text content."),
      },
      annotations: { readOnlyHint: true },
    }, async ({ session_id, file_path, text_only = true }) => {
      if (!session_id && !file_path) {
        return toolError("Provide either session_id or file_path");
      }
    
      let resolvedPath = file_path;
    
      if (!resolvedPath && session_id) {
        const { readdirSync: rd, statSync: st } = await import("node:fs");
        const { homedir } = await import("node:os");
        const projectsDir = join(homedir(), ".claude", "projects");
        try {
          const projectDirs = rd(projectsDir, { withFileTypes: true });
          for (const dir of projectDirs) {
            if (!dir.isDirectory()) continue;
            const candidate = join(projectsDir, dir.name, `${session_id}.jsonl`);
            try { st(candidate); resolvedPath = candidate; break; } catch { /* not here */ }
          }
        } catch { /* projects dir missing */ }
        if (!resolvedPath) {
          return toolError(`No transcript found for session_id: ${session_id}`);
        }
      }
    
      const result = parseTranscriptFile(resolvedPath!);
      const messages = text_only ? getTextMessages(result) : result.messages;
    
      return toolResult({
        session_id: result.sessionId,
        cwd: result.cwd,
        format_version: result.formatVersion,
        line_count: result.lineCount,
        message_count: result.messages.length,
        text_message_count: getTextMessages(result).length,
        warnings: result.warnings,
        messages: messages.map((m) => ({
          role: m.role,
          timestamp: m.timestamp,
          uuid: m.uuid,
          parentUuid: m.parentUuid,
          text: m.textContent,
        })),
      });
    });
  • The handler function for graph_read_transcript resolves either session_id (by searching ~/.claude/projects/) or file_path, then calls parseTranscriptFile() and returns normalized messages with text content.
    }, async ({ session_id, file_path, text_only = true }) => {
      if (!session_id && !file_path) {
        return toolError("Provide either session_id or file_path");
      }
    
      let resolvedPath = file_path;
    
      if (!resolvedPath && session_id) {
        const { readdirSync: rd, statSync: st } = await import("node:fs");
        const { homedir } = await import("node:os");
        const projectsDir = join(homedir(), ".claude", "projects");
        try {
          const projectDirs = rd(projectsDir, { withFileTypes: true });
          for (const dir of projectDirs) {
            if (!dir.isDirectory()) continue;
            const candidate = join(projectsDir, dir.name, `${session_id}.jsonl`);
            try { st(candidate); resolvedPath = candidate; break; } catch { /* not here */ }
          }
        } catch { /* projects dir missing */ }
        if (!resolvedPath) {
          return toolError(`No transcript found for session_id: ${session_id}`);
        }
      }
    
      const result = parseTranscriptFile(resolvedPath!);
      const messages = text_only ? getTextMessages(result) : result.messages;
    
      return toolResult({
        session_id: result.sessionId,
        cwd: result.cwd,
        format_version: result.formatVersion,
        line_count: result.lineCount,
        message_count: result.messages.length,
        text_message_count: getTextMessages(result).length,
        warnings: result.warnings,
        messages: messages.map((m) => ({
          role: m.role,
          timestamp: m.timestamp,
          uuid: m.uuid,
          parentUuid: m.parentUuid,
          text: m.textContent,
        })),
      });
    });
  • parseTranscriptFile() is the canonical transcript parser that reads a JSONL file, parses each line, extracts messages with role/content/timestamp/uuid, and returns a ParseResult with format version detection and warnings.
    export function parseTranscriptFile(filePath: string): ParseResult {
      const warnings: string[] = [];
      const messages: TranscriptMessage[] = [];
      let sessionId: string | null = null;
      let cwd: string | null = null;
    
      let raw: string;
      try {
        raw = readFileSync(filePath, "utf-8");
      } catch (err) {
        return {
          messages: [],
          sessionId: null,
          cwd: null,
          formatVersion: "unknown",
          lineCount: 0,
          warnings: [`Failed to read file: ${err instanceof Error ? err.message : String(err)}`],
        };
      }
    
      const lines = raw.split("\n").filter((l) => l.trim().length > 0);
      const lineCount = lines.length;
    
      const sample: unknown[] = [];
      for (const line of lines.slice(0, 5)) {
        try { sample.push(JSON.parse(line)); } catch { /* skip */ }
      }
      const formatVersion = detectFormatVersion(sample);
    
      if (formatVersion === "unknown") {
        warnings.push(
          "Unrecognized transcript format — expected fields type/message/sessionId not found. " +
          "The Claude Code JSONL format may have changed; update transcript-parser.ts.",
        );
      }
    
      for (let i = 0; i < lines.length; i++) {
        let parsed: unknown;
        try {
          parsed = JSON.parse(lines[i]);
        } catch {
          warnings.push(`Line ${i + 1}: invalid JSON, skipped`);
          continue;
        }
    
        if (!parsed || typeof parsed !== "object") {
          warnings.push(`Line ${i + 1}: not an object, skipped`);
          continue;
        }
    
        const obj = parsed as Record<string, unknown>;
    
        if (!("type" in obj)) {
          warnings.push(`Line ${i + 1}: missing "type" field, skipped`);
          continue;
        }
    
        const type = obj["type"] as string;
    
        // Skip known non-message record types silently
        if (KNOWN_NON_MESSAGE_TYPES.has(type)) continue;
    
        // Skip unknown record types without warning — future format additions shouldn't be noisy
        if (!MESSAGE_TYPES.has(type)) continue;
    
        const msg = obj["message"] as Record<string, unknown> | undefined;
        // system records may be structural metadata (e.g. stop_hook_summary) with no message payload
        if (!msg || typeof msg !== "object") continue;
    
        const role = (msg["role"] as string) || type;
        if (!MESSAGE_TYPES.has(role)) continue;
    
        const content = normalizeContent(msg["content"] ?? []);
        const sid = (obj["sessionId"] as string) ?? "";
        const cwdVal = (obj["cwd"] as string) ?? "";
    
        if (!sessionId && sid) sessionId = sid;
        if (!cwd && cwdVal) cwd = cwdVal;
    
        messages.push({
          role: role as "user" | "assistant" | "system",
          content,
          timestamp: (obj["timestamp"] as string) ?? "",
          sessionId: sid,
          cwd: cwdVal,
          uuid: (obj["uuid"] as string) ?? "",
          parentUuid: obj["parentUuid"] as string | undefined,
          model: obj["model"] as string | undefined,
          textContent: extractText(content),
        });
      }
    
      return { messages, sessionId, cwd, formatVersion, lineCount, warnings };
    }
    
    /** Returns only messages that have extractable text content (skips pure tool calls). */
    export function getTextMessages(result: ParseResult): TranscriptMessage[] {
      return result.messages.filter((m) => m.textContent.length > 0);
    }
  • getTextMessages() filters parsed messages to only those with extractable text content (skips pure tool calls).
    export function getTextMessages(result: ParseResult): TranscriptMessage[] {
      return result.messages.filter((m) => m.textContent.length > 0);
    }
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already indicate readOnlyHint=true, so the description adds value by explaining the parsing and normalization process. No contradictions are present, and the description provides additional context beyond what annotations offer.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is extremely concise, consisting of two sentences that immediately convey the tool's purpose and key benefit. Every word serves a purpose, making it efficient for an agent to process.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness3/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool has no output schema, the description mentions 'returns normalized messages with text content extracted' but lacks details on the structure of those messages or edge cases. It covers the essential but leaves some ambiguity about the exact return format.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100% with detailed parameter descriptions. The tool description does not add any extra meaning beyond what the input schema already provides, so it meets the baseline expectation.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool reads and parses a Claude Code JSONL transcript file, using the canonical parser, and returns normalized messages with text content. It differentiates from raw JSONL reading, which is not even a sibling tool, making the purpose specific and unambiguous.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description explicitly advises using this tool instead of reading raw JSONL directly, explaining that it handles format changes centrally. While it doesn't list alternatives, it provides a clear use case and rationale, which is sufficient for an agent to decide when to invoke this tool.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/stevepridemore/graph-memory'

If you have feedback or need assistance with the MCP directory API, please join our Discord server