Langfuse Does Not Run Your Agent. It Wraps the One You Already Have.
Langfuse is the wrap around the agent loop you already ship, not a new framework: same Harbor Desk ticket, three capture paths, and LiteLLM as the lookalike you must not confuse with the OpenAI drop-in.

Langfuse agent tracing is a layer around the loop you already ship. Same support ticket, three capture paths, and one lookalike you should not confuse with the OpenAI drop-in.
You already ship the agent. A bad refund reply still leaves you reconstructing tool calls from print logs that vanish when the process exits.
In this article: You will learn how to put Langfuse around the agent you already run, without rewriting Harbor Desk as a new product. We cover the OpenAI drop-in you may already use, Claude Agent SDK tracing through OpenInference, DeepAgents tracing through a LangChain
CallbackHandler, and why LiteLLM is a different integration. By the end you can send the same ticket through two agent loops and open two trees that later scores and experiments can attach to.
You already shipped the agent. Maya's life jacket arrived soaked and smelling like bilge. Harbor Desk drafted a refund reply that cited the wrong policy, and you reconstructed the run from print() output that vanished when the process exited. The model turns and the lookup_policy call were never a tree you could open.
That is not an agent-framework problem. It is a wrap problem. Langfuse agent tracing does not replace the Claude Agent SDK or DeepAgents. It sits around the loop you already run and records the Harbor Desk job you already understand: classify the ticket, retrieve a policy, draft a reply.
This article is the glue. Same ticket in. Same policy table. Same labels: Maya, ticket-4412, refund-flow. Three capture paths, plus the LiteLLM lookalike that is not the OpenAI drop-in.
Same ticket. Three capture paths.
Harbor Desk is a support-ticket assistant. A ticket comes in. The system classifies the intent as refund, shipping, or other, looks up a short policy snippet, and drafts a reply that is allowed to use only that policy. Many teams first see a Langfuse tree by wrapping a tiny OpenAI script. That script is never the product. The product is the Claude Agent SDK loop or DeepAgents graph you already ship.
Pick the path that matches how you call the model. Do not rebuild the ticket desk three times.

Path 1: the Langfuse OpenAI drop-in
You may already have this one. Python replaces the import with from langfuse.openai import openai, or with OpenAI / AzureOpenAI from the same module. TypeScript wraps the official client: observeOpenAI(new OpenAI()), after LangfuseSpanProcessor is running.
This is a wrapped official OpenAI SDK. It is not a proxy. It is not a multi-provider router. It is the cheapest first trace, which is why a hello-trace script uses it. Keep it for tiny scripts and for any Harbor Desk path that still calls OpenAI directly.
If your production loop is already an agent, this path is the recap, not the destination.
Path 2: Claude Agent SDK via OpenInference
Langfuse does not instrument the Agent SDK itself. OpenInference does. You turn that instrumentor on, then write a normal Agent SDK program. Every tool call and model turn becomes an OpenTelemetry span and lands in Langfuse.
pip install langfuse claude-agent-sdk openinference-instrumentation-claude-agent-sdk
Harbor Desk is still a ClaudeSDKClient plus a lookup_policy tool. The wrap is a handle-ticket span, propagate_attributes, and a draft-reply generation around that client.
import asyncio
from typing import Any
from claude_agent_sdk import (
ClaudeAgentOptions,
ClaudeSDKClient,
create_sdk_mcp_server,
tool,
)
from langfuse import get_client, propagate_attributes
from openinference.instrumentation.claude_agent_sdk import ClaudeAgentSDKInstrumentor
ClaudeAgentSDKInstrumentor().instrument() # ①
langfuse = get_client()
POLICIES = {
"refund": "Full refund within 30 days if the goods arrived damaged.",
"shipping": "Tracked shipping in 3 to 5 business days.",
"other": "A human will follow up within one business day.",
}
@tool(
"lookup_policy",
"Look up the harbor-shop support policy for an intent.",
{"intent": str},
)
async def lookup_policy(args: dict[str, Any]) -> dict[str, Any]: # ②
intent = str(args.get("intent", "other")).lower()
policy = POLICIES.get(intent, POLICIES["other"])
return {"content": [{"type": "text", "text": policy}]}
async def handle_ticket(ticket: str) -> str:
"""Run Harbor Desk and return the draft reply.
Later parts score and experiment on this return value. Keep it.
"""
server = create_sdk_mcp_server(
name="harbor",
version="1.0.0",
tools=[lookup_policy],
)
options = ClaudeAgentOptions(
model="claude-sonnet-4-5-20250929",
system_prompt=(
"You are Harbor Desk. Classify the ticket as refund, shipping, "
"or other, call lookup_policy, then draft a short reply that "
"uses only that policy."
),
mcp_servers={"harbor": server},
allowed_tools=["mcp__harbor__lookup_policy"],
)
draft = ""
with langfuse.start_as_current_observation( # ③
as_type="span",
name="handle-ticket",
input={"ticket": ticket},
) as root:
with propagate_attributes( # ④
user_id="maya@harbor",
session_id="ticket-4412",
tags=["refund-flow"],
metadata={"order_id": "4412"},
version="harbor-desk-0.1.0",
):
with langfuse.start_as_current_observation( # ⑤
as_type="generation",
name="draft-reply",
) as generation:
async with ClaudeSDKClient(options=options) as client: # ⑥
await client.query(ticket)
async for message in client.receive_response():
print(message)
text = getattr(message, "result", None)
if isinstance(text, str) and text.strip():
draft = text
generation.update(output=draft)
root.update(output={"reply": draft})
return draft
reply = asyncio.run(
handle_ticket(
"Order 4412. The life jacket arrived soaked and smells like bilge. "
"I want a refund, not a replacement."
)
)
print(reply)
langfuse.flush()
① The OpenInference instrumentor is the only Langfuse-specific setup line. After this, every tool call and model turn becomes an OpenTelemetry span. ② lookup_policy is ordinary Agent SDK Harbor Desk. Langfuse does not own the tool; it only observes it. ③ start_as_current_observation opens the root handle-ticket span later parts can target, and records the ticket as input. ④ propagate_attributes stamps Maya, ticket-4412, and refund-flow onto every child span, the same as Part 4. ⑤ The draft-reply generation is a named observation Part 7 can score. The instrumentor fills the middle. ⑥ ClaudeSDKClient is the loop you already ship. The draft string is collected from the result and returned so later parts can call handle_ticket and score it.
Note: The full extracted listing at code/langfuse/part-5-wrapping-the-agents-you-already-have/listings/01-agent-sdk-harbor-desk.py is the complete runnable program.
ClaudeAgentSDKInstrumentor().instrument() is the whole Langfuse-specific line. The rest is Agent SDK: a @tool, an in-process MCP server, allowed_tools, and ClaudeSDKClient. Wrap the run in propagate_attributes so Maya and ticket-4412 still land on every span.

TypeScript uses @arizeai/openinference-instrumentation-claude-agent-sdk and has to keep that instrumentation scope when the LangfuseSpanProcessor filters spans. The official cookbook uses shouldExportSpan plus isDefaultExportSpan so that OpenInference spans are not dropped.
import { NodeSDK } from "@opentelemetry/sdk-node";
import { LangfuseSpanProcessor, isDefaultExportSpan } from "@langfuse/otel";
import { ClaudeAgentSDKInstrumentation } from "@arizeai/openinference-instrumentation-claude-agent-sdk";
import * as ClaudeAgentSDK from "@anthropic-ai/claude-agent-sdk";
const instrumentation = new ClaudeAgentSDKInstrumentation(); // ①
instrumentation.manuallyInstrument(ClaudeAgentSDK); // ②
export const sdk = new NodeSDK({
spanProcessors: [
new LangfuseSpanProcessor({
shouldExportSpan: ({ otelSpan }) => // ③
isDefaultExportSpan(otelSpan) ||
otelSpan.instrumentationScope.name ===
"@arizeai/openinference-instrumentation-claude-agent-sdk",
}),
],
instrumentations: [instrumentation],
});
sdk.start(); // ④
① ClaudeAgentSDKInstrumentation is the OpenInference instrumentor for TypeScript. ② manuallyInstrument attaches that instrumentor to the official Agent SDK module. ③ shouldExportSpan keeps both Langfuse's default spans and this OpenInference scope, so the Agent SDK spans are not dropped. ④ sdk.start() must run before the query you want traced.
Note: The full extracted listing at code/langfuse/part-5-wrapping-the-agents-you-already-have/listings/02-openinference-span-export.ts is the complete processor and instrumentor glue.
const { query } = ClaudeAgentSDK;
for await (const message of query({
prompt:
"Order 4412. The life jacket arrived soaked. Classify, look up policy, draft a reply.",
options: { model: "claude-sonnet-4-5" },
})) {
if (message.type === "assistant") {
console.log(message.message.content);
}
}
await sdk.shutdown();
That TypeScript block is the glue only: processor, instrumentor, and shouldExportSpan. Port the Python Harbor Desk tools and propagateAttributes yourself; the cookbook query() snippet is not a second Harbor Desk.
The JS/TS-specific rule still holds: start NodeSDK before the code you want traced. Python's get_client() is less order-sensitive. Both still need flush() / shutdown() on the way out of a script.
What the trace should look like
Open the trace. You want a root handle-ticket, a draft-reply generation that a later judge can target, the lookup_policy tool, and the model turns that the instrumentor adds in the middle. Keep returning the draft string. Scoring and experiment runners will call await handle_ticket(ticket) and judge that return value.

Stable names are the API. Rename draft-reply later and you silently break the evaluator that looks for it.
Path 3: DeepAgents via LangChain callbacks
DeepAgents is LangChain. Langfuse's hook is not OpenInference. It is CallbackHandler from langfuse.langchain, passed on invoke.
pip install langfuse deepagents
from deepagents import create_deep_agent
from langfuse import get_client, propagate_attributes
from langfuse.langchain import CallbackHandler
langfuse = get_client()
langfuse_handler = CallbackHandler() # ①
POLICIES = {
"refund": "Full refund within 30 days if the goods arrived damaged.",
"shipping": "Tracked shipping in 3 to 5 business days.",
"other": "A human will follow up within one business day.",
}
def lookup_policy(intent: str) -> str:
"""Look up the harbor-shop support policy for an intent."""
return POLICIES.get(intent.lower(), POLICIES["other"])
agent = create_deep_agent( # ②
tools=[lookup_policy],
system_prompt=(
"You are Harbor Desk. Classify the ticket as refund, shipping, "
"or other, call lookup_policy, then draft a short reply that "
"uses only that policy."
),
)
with propagate_attributes( # ③
user_id="maya@harbor",
session_id="ticket-4412",
tags=["refund-flow"],
metadata={"order_id": "4412"},
):
result = agent.invoke(
{
"messages": [
{
"role": "user",
"content": (
"Order 4412. The life jacket arrived soaked and "
"smells like bilge. I want a refund, not a replacement."
),
}
]
},
config={"callbacks": [langfuse_handler]}, # ④
)
print(result)
langfuse.flush() # ⑤
① CallbackHandler is the Langfuse object for this path, not OpenInference. ② create_deep_agent is ordinary DeepAgents Harbor Desk: the same policy tool and system prompt as Path 2. ③ propagate_attributes keeps Maya and ticket-4412 on every span, the same as the Agent SDK path. ④ Passing the handler on invoke is the hook. Omit it and DeepAgents still runs, but Langfuse stays empty. ⑤ flush sends the last spans before the script exits.
Note: The full extracted listing at code/langfuse/part-5-wrapping-the-agents-you-already-have/listings/03-deepagents-callback-handler.py is the complete runnable program.
The official cookbook is Python only, so this path stays Python. The Langfuse-specific object is CallbackHandler. If you forget it on invoke, DeepAgents still runs, and Langfuse stays empty. LangChain @tool methods become tool observations automatically; frameworks set types so you do not have to.
You can auth_check() on get_client() if you want a loud failure before the first ticket.

LiteLLM is not the OpenAI drop-in
LiteLLM is a unified SDK and proxy over many providers. Langfuse talks to it two other ways:
- LiteLLM SDK:
litellm.callbacks = ["langfuse_otel"], thenlitellm.completion(...). Credentials are the sameLANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY. OptionalLANGFUSE_OTEL_HOSTfor a custom OTEL origin. - LiteLLM Proxy: a gateway. Different page, different deploy.
Harbor Desk is not a LiteLLM app here. If you already route every model through LiteLLM, use that integration. Do not replace from langfuse.openai import openai with LiteLLM and assume it is the same drop-in. It is not.

What you keep after the wrap
The Langfuse API does not care which path produced the tree. Prompts, scores, and experiments attach to observations. Wrap the agent run in propagate_attributes so that Maya, ticket-4412, and refund-flow still exist. Prefer stable names on any span you create yourself.
Pick a default going forward. Claude Agent SDK is the primary agent-shaped path in Python and TypeScript. DeepAgents is the second path, worth showing again only when the hook differs. The OpenAI drop-in stays around for tiny scripts and for any call that is still a raw chat completion.
Do this today
- Install
openinference-instrumentation-claude-agent-sdkand callClaudeAgentSDKInstrumentor().instrument()before you constructClaudeSDKClient. - Wrap
handle_ticketin ahandle-ticketspan,propagate_attributesfor Maya andticket-4412, and adraft-replygeneration. Return the draft string. - Run the soaked-life-jacket ticket through that Agent SDK path and call
langfuse.flush(). - Run the same ticket through DeepAgents with
config={"callbacks": [CallbackHandler()]}and flush again. - Open both traces. Confirm a root
handle-ticket(or the DeepAgents equivalent), a policy tool, and the user, session, and tag you set.
The wrap is the product
Langfuse is the layer around the loop, not the loop. The OpenAI drop-in is one import. The Agent SDK needs an OpenInference instrumentor. DeepAgents needs a LangChain CallbackHandler on invoke. LiteLLM is a gateway with its own callback.
You did not need a new Harbor Desk. You needed the right glue on the one you already have. Same ticket. Different wrap. One tree you can actually open the next time Maya's jacket comes back wet.