

How it works
- A global “Stop” hook is configured to run each time Claude Code responds.
- The hook reads Claude Code’s generated conversation transcripts.
- 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:- Claude Code CLI installed.
- LangSmith API key (get it here).
- Command-line tool
jq- JSON processor (install guide)
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:
`stop_hook.sh` file
`stop_hook.sh` file
Copy
Ask AI
#!/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
Copy
Ask AI
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.
Copy
Ask AI
"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 tofalseto disable tracing.CC_LANGSMITH_API_KEY- Your LangSmith API keyCC_LANGSMITH_PROJECT- The LangSmith project name where traces are sent- (optional)
CC_LANGSMITH_DEBUG: "true"- Enables detailed debug logging. Remove or set tofalseto disable tracing.
Copy
Ask AI
{
"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_idand can be viewed in the Threads tab of a project.
Troubleshooting
No traces appearing in LangSmith
-
Check the hook is running:
You should see log entries after each Claude response.CopyAsk AI
tail -f ~/.claude/state/hook.log -
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
- Check that
-
Enable debug mode to see detailed API activity:
Then check logs for API calls and HTTP status codes.CopyAsk AI
{ "env": { "CC_LANGSMITH_DEBUG": "true" } }
Permission errors
Make sure the hook script is executable:Copy
Ask AI
chmod +x ~/.claude/hooks/stop_hook.sh
Required commands not found
Verify all required commands are installed:Copy
Ask AI
which jq curl uuidgen
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:
Copy
Ask AI
# 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.