Skip to main content

Telem Python SDK

A typed Python client for the Telem search orchestration API. It wraps the backend's interaction endpoint and reads back the server's normalized envelope — one result shape for every provider — with both synchronous and asynchronous clients.

Full documentation, including quickstarts for every surface (SDK, MCP, OpenClaw, opencode, pi): docs.telem.ai (launching soon).

Installation

Coming soon: telem-sdk is not yet published to PyPI, so the command below does not work yet. Request early access at docs.telem.ai — it carries the current install instructions. With repository access, install from a local checkout with uv sync (see CONTRIBUTING.md).

pip install telem-sdk

Requires Python 3.12+.

Quickstart

from telem import Telem

client = Telem()  # reads TELEM_API_KEY and TELEM_BASE_URL from the environment
results = client.search("best python http client").results
for r in results:
    print(r.title, r.url)

Configuration

The client is credential-agnostic and resolves configuration from arguments, then the environment, then defaults:

Setting Argument Env var Default
API key api_key TELEM_API_KEY none (anonymous)
Base URL base_url TELEM_BASE_URL https://router.telem.ai
Result tier default_tier TELEM_TIER the server's (default)
Explicit fields default_fields TELEM_FIELDS (csv) unset (the tier decides)
Provider allow-list default_providers_include TELEM_PROVIDERS_INCLUDE (csv) the deployment's set
Provider deny-list default_providers_exclude TELEM_PROVIDERS_EXCLUDE (csv) unset
Full page content default_include_full_content TELEM_FULL_CONTENT (1 only) off

When an API key is set, requests carry an Authorization: Bearer <key> header. Anonymous access works for most endpoints locally; sessions.list() requires a token.

Every search default resolves as call argument → constructor argument → env var → unset. A csv env var is split on commas with items stripped and empties dropped, so an all-empty value reads as unset: only a constructor argument can express an explicit empty list (default_fields=[], a deliberate "send nothing" the server rejects). TELEM_TIER and TELEM_FULL_CONTENT follow the same rule — TELEM_FULL_CONTENT enables full content for exactly the value 1. TELEM_PROVIDERS is NOT read by the SDK: it belongs to the MCP server and the opencode plugin, where it survives as a deprecated alias of the provider allow-list. Export TELEM_PROVIDERS_INCLUDE for both.

num_results, include_raw and provider_overrides are deliberately call-level only: per-call intent, an audit knob and a surgical escape hatch respectively.

client = Telem(api_key="tlm_...", base_url="https://router.telem.ai", default_tier="extended")

The request timeout defaults to 60 s (the server grants provider timeouts that long at the max tier and with full content); pass timeout= to change it.

What Telem receives

Telem is a hosted service, so everything described here leaves your process and reaches Telem's servers. What gets sent depends entirely on which entry point you use.

A plain Telem().search() sends only what you hand it — the query (or queries), plus any goal, context and metadata you pass, and the search options themselves. There is no ambient collection: no conversation, no files, no environment.

The agent integrations send the conversation. Both the OpenAI wrap (client.wrap()) and the LangChain/LangGraph tool attach a snapshot of the surrounding conversation to every search request, as metadata["message_history"]. That is what the integrations are for — it is how the backend sees what the agent is actually working on — but it means the conversation text is transmitted, so choose them deliberately.

Sent verbatim, per message:

  • user, system and assistant message text (OpenAI's developer role is sent as system);
  • provider reasoning text, when the model provider returns it (OpenRouter, DeepSeek, ...);
  • one compact marker per tool call — [tool <name>: <status> <arguments>] — carrying the tool's name, its status (running, completed or pending) and its arguments.

Each of those fields is truncated at 128 000 characters.

Not sent:

  • Tool results. Messages with role tool are dropped entirely. A tool call is represented only by its marker; whatever it returned — a file, a page, a database row — never reaches Telem through the history.
  • Your conversation identifiers, in raw form. The OpenAI wrap's conversation_id and LangGraph's configurable.thread_id are hashed; only the derived session_key and fingerprint go on the wire.

When it is sent: only on a search. The wrap records messages locally as the conversation runs, and wrapped.telem_messages exposes that recording. Nothing leaves the process until the model calls telem_search and a search request actually goes out.

Opting out. use_telem="none" on a wrapped create() call skips Telem for that call entirely, and a plain Telem().search() never sends history. Beyond those two, there is no history-free search mode: a search issued from inside a wrapped conversation or a LangGraph run always carries metadata["message_history"]. (A LangChain tool invoked directly, outside a graph, has no graph state to read and so sends none — but that is the absence of a conversation, not an opt-out.)

Separately from Telem: queries, model-facing result text and the full ToolMessage/SearchResponse artifact can also leave your application through your model provider or your tracing backend. Review every recipient's retention settings, not just this one.

Search

search() performs a single round trip (POST /v1/interactions) and returns the server's normalized envelope for every provider that ran — every provider's rows come back in one shape, whatever its own API looks like:

resp = client.search(
    "climate policy 2026",
    tier="extended",             # minimalist | default | extended | max
    providers_include=["exa"],   # omit to use the deployment's default provider set
    num_results=10,              # rows PER PROVIDER (server default 5, range 1..20)
    include_raw=True,            # also attach each provider's own response body
    goal="brief the user",       # merged into request metadata
    context="follow-up query",   # merged into request metadata
)

resp.results        # flattened list[SearchResult]: providers in run order, rows in envelope order
resp.by_provider    # list[ProviderRun] — the primary surface; keeps partial failures
resp.session_id     # continue the conversation by passing session=resp.session_id
resp.status         # "succeeded" | "partially_succeeded" | "failed"
resp.normalized_schema_version   # the contract the server answered with

The full option set is tier, fields, providers_include, providers_exclude, provider_overrides, num_results, include_raw, include_full_content, plus goal/context/session/metadata. None means unset (fall through to the client default, then to the server's own default); [], {}, False and True are all explicit values and are sent verbatim. Nothing is pre-validated client-side — tier names, field names and the num_results bounds are the server's call and come back as BadRequestError.

Two options compose rather than stack: a fields list replaces any tier (the level that set it wins, and fields wins a tie), and when both provider halves are set the excluded names are subtracted from the allow-list, which then fully determines the set.

provider_overrides is the per-provider escape hatch: raw parameters merged into ONE provider's request body, keyed by provider name and written in that provider's own vocabulary, not the SDK's:

resp = client.search("climate policy 2026", provider_overrides={"exa": {"numResults": 2}})

An overridden provider gets its raw payload attached automatically, so you can see what the override actually did.

Each SearchResult exposes url, title, summary, excerpt, full_content, publish_date, rank, thumbnail, favicon, source (an object: domain/name/ author), enrichments, fetch_meta, plus provider and raw (the verbatim envelope row). result.content survives as a legacy aliassummary, else full_content["content"], else "" — as a property, not a stored field: it never appears in model_dump().

What you actually get back

Captured from a live deployment (2026-07-28, values trimmed). A default-tier row:

>>> run = resp.by_provider[0]
>>> (run.provider, run.status, run.tier, run.query)
('exa', 'succeeded', 'default', 'best onsen towns near kyoto')
>>> run.results[0].model_dump(exclude_none=True)
{'url': 'https://sugoii-japan.com/best-onsen-towns-near-kyoto',
 'title': 'The 7 Best Onsen Towns Near Kyoto You Have To Explore',
 'rank': 1,
 'summary': 'Here are the best onsen towns near Kyoto highlighted in the article: ...',
 'provider': 'exa', 'raw': {...}}

At tier="max" the run also carries the query-level fields. Their shapes are exactly what the server's contract pins — note that related is an object, not a list:

>>> run = client.search("climate policy 2026", tier="max",
...                     providers_include=["serpapi"]).by_provider[0]
>>> run.related
{'questions': [],
 'searches': ['Climate policy 2026 update', 'Climate policy 2026 summary',
              'Is climate change getting better in 2026', ...]}
>>> run.results[0].publish_date
'2026-01-16'
>>> run.answer        # str | None — filled when the provider returned a direct answer
None

entities and verticals are provider-native blocks (dicts), usage is dict or list (parallel reports a list), and warnings is a list of {"code", "message"} objects — a provider that cannot supply a requested field says so there (capability_gap) instead of failing the run.

Two things to know when moving from the pre-V2 SDK:

  • providers= is now providers_include= (under V2 a caller-sent provider list on the old wire path is a 400).
  • max_results= is gone. It capped the flattened list client-side after paying for every row; num_results asks the server for a per-provider count, so it is a real cost knob rather than a truncation.

Every search() checks that the server echoed normalized_schema_version >= 2 and raises TelemServerVersionError otherwise — a pre-V2 backend, or a dev deployment with no adapter-backed providers configured, fails loudly instead of returning empty results.

Passing a sequence of queries batches them into a single interaction — the backend runs them concurrently, and each entry in by_provider is tagged with the query it served:

resp = client.search(["query a", "query b"])

for run in resp.by_provider:
    print(run.batch_index, run.query, run.provider, len(run.results))

resp.results stays the flattened list across all runs. A one-element sequence behaves exactly like a plain string.

Providers

for p in client.providers():
    print(p.name, p.active_by_default, p.normalized, p.tiers)

normalized marks the providers that return the V2 envelope (they are the ones a search can select), and tiers lists the tier names each of them serves.

Sessions

client.sessions.list()                      # list[SessionSummary] (requires an API key)
client.sessions.history(session_id)         # short history
client.sessions.history(session_id, full=True)  # detailed history
client.sessions.results(session_id)         # aggregated websearch preprocessor results

LangChain and LangGraph integration

Install the optional integration to create a native LangChain tool:

pip install "telem-sdk[langchain]"
from telem import Telem
from telem.integrations.langchain import create_telem_search_tool

client = Telem()
telem_search = create_telem_search_tool(
    client,
    providers_include=["exa"],
    num_results=5,
)

Only query is exposed in the model's tool schema. Search policy — tier, providers, result count, metadata, and the other search() options — is fixed by application code when the tool is created. The tool returns compact text to the model and keeps the full typed SearchResponse in ToolMessage.artifact for application code.

LangChain agent

Install langchain and the package for your model provider. The current create_agent runtime uses LangGraph internally and accepts the Telem tool directly:

from langchain.agents import create_agent
from langchain_openai import ChatOpenAI

agent = create_agent(ChatOpenAI(model="gpt-5.4-mini"), tools=[telem_search])
result = agent.invoke(
    {"messages": [{"role": "user", "content": "What changed today?"}]},
    config={"configurable": {"thread_id": "application-conversation-42"}},
)

LangGraph ToolNode

Applications using the lower-level graph API can pass the same tool to ToolNode; there is no separate LangGraph implementation:

from langgraph.prebuilt import ToolNode

tool_node = ToolNode([telem_search])

Pass an AsyncTelem client to create an async-only tool and run the graph with ainvoke(). Telem's typed API errors propagate to LangGraph, whose ToolNode error policy can handle them normally.

Trajectory v5

When LangGraph executes the tool, its hidden ToolRuntime supplies the active message state. Every search sends a compact conversation snapshot as metadata.message_history together with flat trajectory-v5 identity fields. Configure a stable thread_id to keep one conversation fingerprint across invocations; the raw ID is hashed and never sent directly to Telem:

config = {"configurable": {"thread_id": "application-conversation-42"}}

Appending messages leaves the context-window session_key unchanged. The first visible message identifies the default window generation, so summarization that removes the old prefix rotates the key. Applications with custom compaction or rewind behavior can set configurable.telem_context_window_id explicitly. Without a thread ID the integration uses message and tool-call IDs as a best-effort one-shot identity; missing bookkeeping never prevents a search.

LangGraph has no universal API for discovering that one graph spawned another. A parent tool can freeze its current state and explicitly link a child with:

from langgraph.prebuilt import ToolRuntime
from telem.integrations.langchain import create_telem_child_config

def delegate_to_child(task: str, runtime: ToolRuntime):
    child_config = create_telem_child_config(
        runtime,
        child_thread_id="child-conversation-7",
    )
    return child_agent.invoke(
        {"messages": [{"role": "user", "content": task}]},
        config=child_config,
    )

The helper carries a frozen root-first ancestor chain, so nested children work by calling it again from the immediate parent. The generated v5 request has no body.session_id and no legacy metadata.trajectory block.

LangChain callbacks, tags, and trace metadata use the normal invocation configuration and therefore work with any compatible tracing handler:

result = agent.invoke(
    {"messages": [{"role": "user", "content": "What changed today?"}]},
    config={
        "callbacks": [callback_handler],
        "tags": ["research"],
        "metadata": {"request_id": "request-123"},
    },
)

The factory's metadata= is sent to the Telem backend alongside the generated v5 fields. Its tags= and trace_metadata= label the LangChain tool run and are sent to callback handlers, not Telem.

Every graph-executed search sends the conversation — see What Telem receives for exactly which fields go on the wire.

Telem interoperates with Langfuse and other tracing backends through standard LangChain callbacks, and adds no vendor-specific runtime integration or dependency. Contributors with repo access will find the tracing setup guide and the manual smoke launchers in CONTRIBUTING.md.

Agent integration (OpenAI wrap)

client.wrap() patches an OpenAI client in place (exa-style — the same object is returned) so the model can call Telem search as a telem_search tool. One wrapped client = one agent conversation = one Telem session; the wrap records the conversation and sends it as metadata["message_history"] with every search — see What Telem receives. Requires the extra: pip install telem-sdk[openai].

from openai import OpenAI
from telem import Telem

client = Telem()
wrapped = client.wrap(OpenAI())   # patched in place; same object returned

r = wrapped.chat.completions.create(model="gpt-4o", messages=msgs)
r.telem_responses                    # list[SearchResponse]; empty if no search ran
wrapped.telem_conversation_id        # this agent's conversation identity
wrapped.telem_session_id             # the backend session, adopted from the first search
wrapped.telem_messages               # recorded conversation snapshot

By default the request's tools are replaced with the telem_search tool (caller tools are dropped, exa parity) and search rounds complete inside create(). Agents with their own tools switch to loop integration:

run_tool = wrapped.wrap_tool_runner(run_my_tool)  # merges tools; create() stops auto-completing

r = wrapped.chat.completions.create(model="gpt-4o", messages=msgs, tools=my_tools)
if r.choices[0].message.tool_calls:
    msgs.append(r.choices[0].message)  # the assistant message that made the tool calls
    for tc in r.choices[0].message.tool_calls:
        msgs.append(run_tool(tc))  # telem_search handled by the SDK, others by run_my_tool

Pass use_telem="none" on a call to skip Telem for that call. AsyncTelem.wrap() mirrors this for AsyncOpenAI; the async runner accepts sync or async tool functions.

Wrap the subagent at the moment you delegate, passing the parent wrapped client. The wrap freezes the parent's conversation right then and carries it as the child's newest ancestor:

root = client.wrap(OpenAI(), goal="answer the user task")
root.chat.completions.create(model="gpt-4o", messages=root_msgs)

# Inside the parent's tool handler, when it decides to spawn a researcher:
subagent = client.wrap(OpenAI(), parent=root, conversation_id="research-1")
subagent.chat.completions.create(model="gpt-4o", messages=subagent_msgs)

Every search the subagent runs then carries parent_node_key (the parent's snapshot) and a root-first ancestors[] chain, so the backend stitches parent and child into one graph. Nested subagents work the same way by passing the spawning subagent as parent.

Wrap the child when you delegate, not at startup. The freeze happens at wrap() time. A child wrapped before its parent has spoken records a delegation with empty parent context, and the wrap cannot detect that.

Conversation identity. conversation_id is auto-minted per wrapped client. Supply your own whenever the conversation outlives one client object — a web server wrapping a fresh client per request must pass a stable thread id, or every request looks like a new conversation. context_window_id is the matching override for the context-window generation; by default the wrap anchors on the first message, so trimming or summarizing the history starts a new generation on its own.

Streaming is not supported yet: stream=True calls emit a UserWarning and bypass Telem entirely (no telem_search tool, no session tracking, no reply recording).

Coding-agent plugins

Three TypeScript plugins give coding agents the same two tools — telem_search (web search, one or more queries per call) and telem_fetch (full page text by URL) — over the same V2 search contract and the same trajectory-v5 session protocol as the OpenAI wrap above. They are separate npm packages with their own configuration, and none of them needs this Python package installed:

All three resolve their search options per call — an edit takes effect on the next search, with no restart — from a project telem.json, then a home one, then the TELEM_* environment variables. Each plugin's page above documents its own file locations and keys.

MCP server

A stateless MCP stdio server exposes the SDK to any MCP host (Claude Code, Claude Desktop, ...). Requires the extra:

pip install 'telem-sdk[mcp]'
telem-mcp                    # or: python -m telem.mcp

Configuration is env-only:

Env var Meaning
TELEM_BASE_URL API base URL (default https://router.telem.ai)
TELEM_API_KEY Optional bearer auth; required for telem_session_history
TELEM_PROVIDERS Comma-separated alias for search.providers.include (server picks when unset)
TELEM_RESULT_MAX_LEN Per-result content cap in characters (default 8000)
TELEM_TIMEOUT Request timeout in seconds (default 60, matching the client)

The client's own defaults (TELEM_TIER, TELEM_FIELDS, TELEM_PROVIDERS_INCLUDE/ TELEM_PROVIDERS_EXCLUDE, TELEM_FULL_CONTENT) apply too; an explicit TELEM_PROVIDERS wins over TELEM_PROVIDERS_INCLUDE for the include list.

Tools:

Tool Does
telem_search Web search; batches multiple queries into one interaction/session
telem_providers Lists configured providers, marking the ones active by default
telem_session_history Shows a session's prior searches (status + query per interaction)

Sessions are model-threaded: every telem_search result leads with its Telem session id and the model passes it back as session_id for every search serving the same user goal, omitting it (and setting a goal) only for a genuinely new goal — the same contract as the OpenClaw plugin. The reasoning is written up in docs/specs/2026-07-23-mcp-session-strategy-design.md in the repository.

Register with Claude Code:

claude mcp add telem -e TELEM_BASE_URL=http://localhost:8000 -e TELEM_PROVIDERS=dummy -- telem-mcp

or via .mcp.json:

{
  "mcpServers": {
    "telem": {
      "command": "telem-mcp",
      "env": {
        "TELEM_BASE_URL": "http://localhost:8000",
        "TELEM_PROVIDERS": "dummy"
      }
    }
  }
}

While the package is unpublished (or for a local checkout), run it through uv instead:

claude mcp add telem -e TELEM_BASE_URL=http://localhost:8000 -- uv run --project /path/to/TelemSDK telem-mcp

Agent Skill

telem-search is an installable Claude Code skill that teaches an agent to search the web by calling this SDK directly — single and batched searches, session continuation, provider selection — with no MCP server involved. It ships inside the package (telem/_skills/telem-search/); Claude Code only loads skills from ~/.claude/skills/ or a project's .claude/skills/, so a console script copies it there:

telem-install-skill              # ~/.claude/skills/telem-search — all projects
telem-install-skill --project    # ./.claude/skills/telem-search — this project only

Pass --force to replace an existing installation; without it the command refuses to overwrite one and exits non-zero. From a checkout, pip install -e . first.

It ships a self-contained one-shot CLI at scripts/search.py inside the installed skill directory.

Async

AsyncTelem mirrors Telem; every request method is a coroutine:

import asyncio
from telem import AsyncTelem

async def main():
    async with AsyncTelem() as client:
        resp = await client.search("best python http client")
        print(len(resp.results))

asyncio.run(main())

Errors

All errors derive from TelemError and carry .message, .status_code, and .body:

Status Exception
400 BadRequestError
401 / 403 AuthError
404 NotFoundError
other non-2xx APIStatusError

Two failures never reach a status code: a request that produced no HTTP response at all raises TelemConnectionError, and a search answered by a pre-V2 server raises TelemServerVersionError.

from telem import Telem, BadRequestError

try:
    Telem().search("hi", providers_include=["does-not-exist"])
except BadRequestError as exc:
    print(exc.status_code, exc.message)

Contributing

Working on the SDK itself? CONTRIBUTING.md covers the development environment, the test suites, linting and the manual smoke launchers. It is written for people with access to the repository; it ships with the source, not with the package.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

telem_sdk-0.1.1.tar.gz (54.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

telem_sdk-0.1.1-py3-none-any.whl (68.7 kB view details)

Uploaded Python 3

File details

Details for the file telem_sdk-0.1.1.tar.gz.

File metadata

  • Download URL: telem_sdk-0.1.1.tar.gz
  • Upload date:
  • Size: 54.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.11

File hashes

Hashes for telem_sdk-0.1.1.tar.gz
Algorithm Hash digest
SHA256 8954be7a0ba8fa6ff7ba903bb8edf756ca3b9adfce5d72299a64c922e887d219
MD5 dbb848b31e7289d2c6dc4700ea074ba6
BLAKE2b-256 ca0e3f19ffc26ef5fc57acc4a790aaf846478e9d3af0ca2685ec7b64516e9941

See more details on using hashes here.

File details

Details for the file telem_sdk-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: telem_sdk-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 68.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.11

File hashes

Hashes for telem_sdk-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 8bb9d0ad6696e88c9c7e9248f9976e750b666500b37fbd954775c6a8cb248ad7
MD5 3aaa878b38da6cf4362cf4052506f546
BLAKE2b-256 ef2b9b084675af9e2b41517a22cc268a245dac8ade4b020da2317d50f5d35994

See more details on using hashes here.

Release history Release notifications | RSS feed

0.1.6

2 files

0.1.5

2 files

0.1.4

2 files

0.1.3

2 files

0.1.2

2 files

This release

0.1.1 This release

2 files

0.1.0

2 files

0.0.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page