Skip to main content
This guide shows you how to automatically send conversations from the Claude Code CLI to LangSmith. Once configured, you can opt-in to sending traces from Claude Code projects to LangSmith. Traces will include user messages, tool calls and assistant responses.
LangSmith UI showing trace from Claude Code.

How it works

  1. A global “Stop” hook is configured to run each time Claude Code responds.
  2. The hook reads Claude Code’s generated conversation transcripts.
  3. Messages in the transcript are converted into LangSmith runs and sent to your LangSmith project.
Tracing is opt-in and is enabled per Claude Code project using environment variables.

Prerequisites

Before setting up tracing, ensure you have:
This guide currently only supports macOS.

1. Create the hook script

stop_hook.sh processes Claude Code’s generated conversation transcripts and sends traces to LangSmith. Create the file ~/.claude/hooks/stop_hook.sh with the following script:
#!/bin/bash
###
# Claude Code Stop Hook - LangSmith Tracing Integration
# Sends Claude Code traces to LangSmith after each response.
###

set -e

# Exit early if tracing disabled
if [ "$(echo "$TRACE_TO_LANGSMITH" | tr '[:upper:]' '[:lower:]')" != "true" ]; then
    exit 0
fi

# Required commands
for cmd in jq curl uuidgen; do
    if ! command -v $cmd &> /dev/null; then
        echo "Error: $cmd is required but not installed" >&2
        exit 0
    fi
done

# Config
API_KEY="${CC_LANGSMITH_API_KEY:-$LANGSMITH_API_KEY}"
PROJECT="${CC_LANGSMITH_PROJECT:-claude-code}"
API_BASE="https://api.smith.langchain.com"
STATE_FILE="$HOME/.claude/state/langsmith_state.json"
LOG_FILE="$HOME/.claude/state/hook.log"
DEBUG="$(echo "$CC_LANGSMITH_DEBUG" | tr '[:upper:]' '[:lower:]')"

# Ensure state directory exists
mkdir -p "$(dirname "$STATE_FILE")"

# Validate API key
if [ -z "$API_KEY" ]; then
    echo "$(date '+%Y-%m-%d %H:%M:%S') [ERROR] CC_LANGSMITH_API_KEY not set" >> "$LOG_FILE"
    exit 0
fi

# Logging function
log() {
    local level="$1"
    shift
    echo "$(date '+%Y-%m-%d %H:%M:%S') [$level] $*" >> "$LOG_FILE"
}

# Debug logging
debug() {
    if [ "$DEBUG" = "true" ]; then
        log "DEBUG" "$@"
    fi
}

# API call helper
api_call() {
    local method="$1"
    local endpoint="$2"
    local data="$3"

    debug "API call: $method $endpoint"

    local response
    local http_code
    response=$(curl -s -w "\n%{http_code}" -X "$method" \
        -H "x-api-key: $API_KEY" \
        -H "Content-Type: application/json" \
        -d "$data" \
        "$API_BASE$endpoint" 2>&1)

    http_code=$(echo "$response" | tail -n1)
    response=$(echo "$response" | head -n-1)

    debug "HTTP $http_code: ${response:0:200}"

    if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
        log "ERROR" "API call failed: $method $endpoint"
        log "ERROR" "HTTP $http_code: $response"
        log "ERROR" "Request data: ${data:0:500}"
        return 1
    fi

    echo "$response"
}

# Load state
load_state() {
    if [ ! -f "$STATE_FILE" ]; then
        echo "{}"
        return
    fi
    cat "$STATE_FILE"
}

# Save state
save_state() {
    local state="$1"
    echo "$state" > "$STATE_FILE"
}

# Get message content
get_content() {
    local msg="$1"
    echo "$msg" | jq -c 'if has("message") then .message.content else .content end'
}

# Check if message is tool result
is_tool_result() {
    local msg="$1"
    local content
    content=$(get_content "$msg")

    echo "$content" | jq -e 'if type == "array" then any(.[]; type == "object" and .type == "tool_result") else false end' > /dev/null 2>&1
}

# Format content blocks for LangSmith
format_content() {
    local msg="$1"
    local content
    content=$(get_content "$msg")

    # Handle string content
    if echo "$content" | jq -e 'type == "string"' > /dev/null 2>&1; then
        echo "$content" | jq '[{"type": "text", "text": .}]'
        return
    fi

    # Handle array content
    if echo "$content" | jq -e 'type == "array"' > /dev/null 2>&1; then
        echo "$content" | jq '[
            .[] |
            if type == "object" then
                if .type == "text" then
                    {"type": "text", "text": .text}
                elif .type == "tool_use" then
                    {"type": "tool_call", "name": .name, "args": .input, "id": .id}
                else
                    .
                end
            elif type == "string" then
                {"type": "text", "text": .}
            else
                .
            end
        ] | if length == 0 then [{"type": "text", "text": ""}] else . end'
        return
    fi

    # Default
    echo '[{"type": "text", "text": ""}]'
}

# Get tool uses from message
get_tool_uses() {
    local msg="$1"
    local content
    content=$(get_content "$msg")

    # Check if content is an array
    if ! echo "$content" | jq -e 'type == "array"' > /dev/null 2>&1; then
        echo "[]"
        return
    fi

    echo "$content" | jq -c '[.[] | select(type == "object" and .type == "tool_use")]'
}

# Find tool result
find_tool_result() {
    local tool_id="$1"
    local tool_results="$2"

    local result
    result=$(echo "$tool_results" | jq -r --arg id "$tool_id" '
        first(
            .[] |
            (if has("message") then .message.content else .content end) as $content |
            if $content | type == "array" then
                $content[] |
                select(type == "object" and .type == "tool_result" and .tool_use_id == $id) |
                if .content | type == "array" then
                    [.content[] | select(type == "object" and .type == "text") | .text] | join(" ")
                elif .content | type == "string" then
                    .content
                else
                    .content | tostring
                end
            else
                empty
            end
        ) // ""
    ')

    if [ -z "$result" ]; then
        echo "No result"
    else
        echo "$result"
    fi
}

# Create LangSmith trace
create_trace() {
    local session_id="$1"
    local turn_num="$2"
    local user_msg="$3"
    local assistant_messages="$4"  # JSON array of assistant messages
    local tool_results="$5"

    local turn_id
    turn_id=$(uuidgen | tr '[:upper:]' '[:lower:]')

    local user_content
    user_content=$(format_content "$user_msg")

    local now
    now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

    # Create top-level turn run
    local turn_data
    turn_data=$(jq -n \
        --arg id "$turn_id" \
        --arg name "Claude Code" \
        --arg project "$PROJECT" \
        --arg session "$session_id" \
        --arg time "$now" \
        --argjson content "$user_content" \
        --arg turn "$turn_num" \
        '{
            id: $id,
            name: $name,
            run_type: "chain",
            inputs: {messages: [{role: "user", content: $content}]},
            start_time: $time,
            session_name: $project,
            extra: {metadata: {thread_id: $session}},
            tags: ["claude-code", ("turn-" + $turn)]
        }')

    debug "Creating turn run: $turn_id"
    api_call "POST" "/runs" "$turn_data" > /dev/null

    # Build final outputs array (accumulates all LLM responses)
    local all_outputs
    all_outputs=$(jq -n --argjson content "$user_content" '[{role: "user", content: $content}]')

    # Process each assistant message (each represents one LLM call)
    local llm_num=0
    local last_llm_end="$now"
    while IFS= read -r assistant_msg; do
        llm_num=$((llm_num + 1))

        # Each LLM starts after the previous one ended
        local llm_start
        if [ $llm_num -eq 1 ]; then
            llm_start="$now"
        else
            llm_start="$last_llm_end"
        fi

        # Create assistant run
        local assistant_id
        assistant_id=$(uuidgen | tr '[:upper:]' '[:lower:]')

        local tool_uses
        tool_uses=$(get_tool_uses "$assistant_msg")

        local assistant_content
        assistant_content=$(format_content "$assistant_msg")

        # Build inputs for this LLM call (includes accumulated context)
        local llm_inputs
        llm_inputs=$(jq -n --argjson outputs "$all_outputs" '{messages: $outputs}')

        local assistant_data
        assistant_data=$(jq -n \
            --arg id "$assistant_id" \
            --arg parent "$turn_id" \
            --arg name "Claude" \
            --arg project "$PROJECT" \
            --arg time "$llm_start" \
            --argjson inputs "$llm_inputs" \
            '{
                id: $id,
                parent_run_id: $parent,
                name: $name,
                run_type: "llm",
                inputs: $inputs,
                start_time: $time,
                session_name: $project,
                extra: {metadata: {ls_provider: "anthropic", ls_model_name: "claude-sonnet-4-5"}},
                tags: ["claude-sonnet-4-5"]
            }')

        debug "Creating assistant run #$llm_num: $assistant_id"
        api_call "POST" "/runs" "$assistant_data" > /dev/null

        # Build outputs for this LLM call
        local llm_outputs
        llm_outputs=$(jq -n --argjson content "$assistant_content" '[{role: "assistant", content: $content}]')

        # Create tool runs
        if [ "$(echo "$tool_uses" | jq 'length')" -gt 0 ]; then
            # Tools start slightly after LLM start
            local tool_start="$llm_start"

            while IFS= read -r tool; do
                local tool_id
                tool_id=$(uuidgen | tr '[:upper:]' '[:lower:]')

                local tool_name
                tool_name=$(echo "$tool" | jq -r '.name // "tool"')

                local tool_input
                tool_input=$(echo "$tool" | jq '.input // {}')

                local tool_use_id
                tool_use_id=$(echo "$tool" | jq -r '.id // ""')

                local tool_data
                tool_data=$(jq -n \
                    --arg id "$tool_id" \
                    --arg parent "$assistant_id" \
                    --arg name "$tool_name" \
                    --arg project "$PROJECT" \
                    --arg time "$tool_start" \
                    --argjson input "$tool_input" \
                    '{
                        id: $id,
                        parent_run_id: $parent,
                        name: $name,
                        run_type: "tool",
                        inputs: {input: $input},
                        start_time: $time,
                        session_name: $project,
                        tags: ["tool"]
                    }')

                debug "Creating tool run: $tool_name ($tool_id)"
                api_call "POST" "/runs" "$tool_data" > /dev/null

                # Find and add tool result
                local result
                result=$(find_tool_result "$tool_use_id" "$tool_results")

                local tool_end
                tool_end=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

                local tool_update
                tool_update=$(jq -n \
                    --arg time "$tool_end" \
                    --arg result "$result" \
                    '{
                        outputs: {output: $result},
                        end_time: $time
                    }')

                api_call "PATCH" "/runs/$tool_id" "$tool_update" > /dev/null

                # Next tool starts after this one ends
                tool_start="$tool_end"

                # Add to this LLM's outputs
                llm_outputs=$(echo "$llm_outputs" | jq \
                    --arg id "$tool_use_id" \
                    --arg result "$result" \
                    '. += [{role: "tool", tool_call_id: $id, content: [{type: "text", text: $result}]}]')

            done < <(echo "$tool_uses" | jq -c '.[]')
        fi

        # Update this assistant run
        local assistant_end
        assistant_end=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

        local assistant_update
        assistant_update=$(jq -n \
            --arg time "$assistant_end" \
            --argjson outputs "$llm_outputs" \
            '{
                outputs: {messages: $outputs},
                end_time: $time
            }')

        api_call "PATCH" "/runs/$assistant_id" "$assistant_update" > /dev/null

        # Save end time for next LLM start
        last_llm_end="$assistant_end"

        # Add to overall outputs
        all_outputs=$(echo "$all_outputs" | jq --argjson new "$llm_outputs" '. += $new')

    done < <(echo "$assistant_messages" | jq -c '.[]')

    # Update turn run with all outputs
    # Filter out user messages from final outputs
    local turn_outputs
    turn_outputs=$(echo "$all_outputs" | jq '[.[] | select(.role != "user")]')

    # Use the last LLM's end time as the turn end time
    local turn_end="$last_llm_end"

    local turn_update
    turn_update=$(jq -n \
        --arg time "$turn_end" \
        --argjson outputs "$turn_outputs" \
        '{
            outputs: {messages: $outputs},
            end_time: $time
        }')

    api_call "PATCH" "/runs/$turn_id" "$turn_update" > /dev/null

    log "INFO" "Created turn $turn_num: $turn_id with $llm_num LLM call(s)"
}

# Main function
main() {
    # Read hook input
    local hook_input
    hook_input=$(cat)

    debug "Hook input: $hook_input"

    # Check stop_hook_active flag
    if echo "$hook_input" | jq -e '.stop_hook_active == true' > /dev/null 2>&1; then
        debug "stop_hook_active=true, skipping"
        exit 0
    fi

    # Extract session info
    local session_id
    session_id=$(echo "$hook_input" | jq -r '.session_id // ""')

    local transcript_path
    transcript_path=$(echo "$hook_input" | jq -r '.transcript_path // ""' | sed "s|^~|$HOME|")

    if [ -z "$session_id" ] || [ ! -f "$transcript_path" ]; then
        log "WARN" "Invalid input: session=$session_id, transcript=$transcript_path"
        exit 0
    fi

    log "INFO" "Processing session $session_id"

    # Load state
    local state
    state=$(load_state)

    local last_line
    last_line=$(echo "$state" | jq -r --arg sid "$session_id" '.[$sid].last_line // -1')

    local turn_count
    turn_count=$(echo "$state" | jq -r --arg sid "$session_id" '.[$sid].turn_count // 0')

    # Parse new messages
    local new_messages
    new_messages=$(awk -v start="$last_line" 'NR > start + 1 && NF' "$transcript_path")

    if [ -z "$new_messages" ]; then
        debug "No new messages"
        exit 0
    fi

    local msg_count
    msg_count=$(echo "$new_messages" | wc -l)
    log "INFO" "Found $msg_count new messages"

    # Group into turns
    local current_user=""
    local current_assistants="[]"  # Array of assistant messages
    local current_msg_id=""  # Current assistant message ID
    local current_assistant_parts="[]"  # Parts of current assistant message
    local current_tool_results="[]"
    local turns=0
    local new_last_line=$last_line

    while IFS= read -r line; do
        new_last_line=$((new_last_line + 1))

        if [ -z "$line" ]; then
            continue
        fi

        local role
        role=$(echo "$line" | jq -r 'if has("message") then .message.role else .role end')

        if [ "$role" = "user" ]; then
            if is_tool_result "$line"; then
                # Add to tool results
                current_tool_results=$(echo "$current_tool_results" | jq --argjson msg "$line" '. += [$msg]')
            else
                # New turn - finalize any pending assistant message
                if [ -n "$current_msg_id" ] && [ "$(echo "$current_assistant_parts" | jq 'length')" -gt 0 ]; then
                    # Merge parts and add to assistants array
                    local merged
                    merged=$(echo "$current_assistant_parts" | jq -s '
                        .[0][0] as $base |
                        (.[0] | map(if has("message") then .message.content else .content end)) as $contents |
                        ($contents | map(if type == "string" then [{"type":"text","text":.}] else . end) | add) as $merged_content |
                        $base | if has("message") then .message.content = $merged_content else .content = $merged_content end
                    ')
                    current_assistants=$(echo "$current_assistants" | jq --argjson msg "$merged" '. += [$msg]')
                    current_assistant_parts="[]"
                    current_msg_id=""
                fi

                # Create trace for previous turn
                if [ -n "$current_user" ] && [ "$(echo "$current_assistants" | jq 'length')" -gt 0 ]; then
                    turns=$((turns + 1))
                    local turn_num=$((turn_count + turns))
                    create_trace "$session_id" "$turn_num" "$current_user" "$current_assistants" "$current_tool_results" || true
                fi

                # Start new turn
                current_user="$line"
                current_assistants="[]"
                current_assistant_parts="[]"
                current_msg_id=""
                current_tool_results="[]"
            fi
        elif [ "$role" = "assistant" ]; then
            # Get message ID
            local msg_id
            msg_id=$(echo "$line" | jq -r 'if has("message") then .message.id else "" end')

            if [ -z "$msg_id" ]; then
                # No message ID, treat as continuation of current message
                current_assistant_parts=$(echo "$current_assistant_parts" | jq --argjson msg "$line" '. += [$msg]')
            elif [ "$msg_id" = "$current_msg_id" ]; then
                # Same message ID, add to current parts
                current_assistant_parts=$(echo "$current_assistant_parts" | jq --argjson msg "$line" '. += [$msg]')
            else
                # New message ID - finalize previous message if any
                if [ -n "$current_msg_id" ] && [ "$(echo "$current_assistant_parts" | jq 'length')" -gt 0 ]; then
                    # Merge parts and add to assistants array
                    local merged
                    merged=$(echo "$current_assistant_parts" | jq -s '
                        .[0][0] as $base |
                        (.[0] | map(if has("message") then .message.content else .content end)) as $contents |
                        ($contents | map(if type == "string" then [{"type":"text","text":.}] else . end) | add) as $merged_content |
                        $base | if has("message") then .message.content = $merged_content else .content = $merged_content end
                    ')
                    current_assistants=$(echo "$current_assistants" | jq --argjson msg "$merged" '. += [$msg]')
                fi

                # Start new assistant message
                current_msg_id="$msg_id"
                current_assistant_parts=$(jq -n --argjson msg "$line" '[$msg]')
            fi
        fi
    done <<< "$new_messages"

    # Process final turn - finalize any pending assistant message
    if [ -n "$current_msg_id" ] && [ "$(echo "$current_assistant_parts" | jq 'length')" -gt 0 ]; then
        local merged
        merged=$(echo "$current_assistant_parts" | jq -s '
            .[0][0] as $base |
            (.[0] | map(if has("message") then .message.content else .content end)) as $contents |
            ($contents | map(if type == "string" then [{"type":"text","text":.}] else . end) | add) as $merged_content |
            $base | if has("message") then .message.content = $merged_content else .content = $merged_content end
        ')
        current_assistants=$(echo "$current_assistants" | jq --argjson msg "$merged" '. += [$msg]')
    fi

    if [ -n "$current_user" ] && [ "$(echo "$current_assistants" | jq 'length')" -gt 0 ]; then
        turns=$((turns + 1))
        local turn_num=$((turn_count + turns))
        create_trace "$session_id" "$turn_num" "$current_user" "$current_assistants" "$current_tool_results" || true
    fi

    # Update state
    local updated
    updated=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

    state=$(echo "$state" | jq \
        --arg sid "$session_id" \
        --arg line "$new_last_line" \
        --arg count "$((turn_count + turns))" \
        --arg time "$updated" \
        '.[$sid] = {last_line: ($line | tonumber), turn_count: ($count | tonumber), updated: $time}')

    save_state "$state"

    log "INFO" "Processed $turns turns"
}

# Run main
main 2>&1 | head -c 10000  # Limit output to prevent hanging

exit 0

Make it executable:
chmod +x ~/.claude/hooks/stop_hook.sh

2. Configure the global hook

Set up a global hook in ~/.claude/settings.json that runs the stop_hook.sh script. The global setting enables you to easily trace any Claude Code CLI project. In ~/.claude/settings.json, add the Stop hook.
"hooks": {
  "Stop": [
    {
      "hooks": [
        {
          "type": "command",
          "command": "bash ~/.claude/hooks/stop_hook.sh",
          "timeout": 30
        }
      ]
    }
  ]
}

3. Enable Tracing

For each Claude Code project (a Claude Code project is a directory with Claude Code initialized) where you want tracing enabled, create or edit Claude Code’s project settings file .claude/settings.local.json to include the following environment variables:
  • TRACE_TO_LANGSMITH: "true" - Enables tracing for this project. Remove or set to false to disable tracing.
  • CC_LANGSMITH_API_KEY - Your LangSmith API key
  • CC_LANGSMITH_PROJECT - The LangSmith project name where traces are sent
  • (optional) CC_LANGSMITH_DEBUG: "true" - Enables detailed debug logging. Remove or set to false to disable tracing.
{
  "env": {
    "TRACE_TO_LANGSMITH": "true",
    "CC_LANGSMITH_API_KEY": "lsv2_pt_...",
    "CC_LANGSMITH_PROJECT": "project-name",
    "CC_LANGSMITH_DEBUG": "true"

  }
}
Alternativley, if you want tracing to LangSmith enabled for all Claude Code sessions, you can add the above JSON to your global Claude Code settings.json file.

4. Verify Setup

Start a Claude Code session in your configured project. Traces will appear in LangSmith after Claude Code responds. In LangSmith, you’ll see:
  • Each message to Claude Code appears as a trace.
  • All turns from the same Claude Code session are grouped using a shared thread_id and can be viewed in the Threads tab of a project.

Troubleshooting

No traces appearing in LangSmith

  1. Check the hook is running:
    tail -f ~/.claude/state/hook.log
    
    You should see log entries after each Claude response.
  2. Verify environment variables:
    • Check that TRACE_TO_LANGSMITH="true" in your project’s .claude/settings.local.json
    • Verify your API key is correct (starts with lsv2_pt_)
    • Ensure the project name exists in LangSmith
  3. Enable debug mode to see detailed API activity:
    {
      "env": {
        "CC_LANGSMITH_DEBUG": "true"
      }
    }
    
    Then check logs for API calls and HTTP status codes.

Permission errors

Make sure the hook script is executable:
chmod +x ~/.claude/hooks/stop_hook.sh

Required commands not found

Verify all required commands are installed:
which jq curl uuidgen
If jq is missing:
  • macOS: brew install jq
  • Ubuntu/Debian: sudo apt-get install jq

Managing log file size

The hook logs all activity to ~/.claude/state/hook.log. With debug mode enabled, this file can grow large:
# View log file size
ls -lh ~/.claude/state/hook.log

# Clear logs if needed
> ~/.claude/state/hook.log

Connect these docs programmatically to Claude, VSCode, and more via MCP for real-time answers.