Skip to content

register()

Configure the Coalex OTLP exporter. Call once at application startup before any other Coalex SDK calls.


Signature

def register(
    *,
    endpoint: str = "https://traces.coalex.ai",
    api_key: str = "dev",
    service_name: str | None = None,  # "coalex-sdk-python" on the resource
    filter_non_ai_spans: bool = False,
    suppress_export_errors: bool = True,
    genai_semconv: bool | None = None,
    capture_content: bool = True,
    prompt_refs: bool | None = None,  # None: on unless COALEX_PROMPT_REFS is 0/false/no/off
    declare: AgentInventory | Sequence[AgentInventory] | None = None,
) -> None
interface RegisterOptions {
    endpoint?: string;          // default: "https://traces.coalex.ai"
    apiKey?: string;            // default: "dev"
    serviceName?: string;       // default: "coalex-sdk-typescript"
    filterNonAiSpans?: boolean; // default: false
    suppressExportErrors?: boolean; // default: true
    declare?: AgentInventory | AgentInventory[];
    promptRefs?: boolean;       // default: on; COALEX_PROMPT_REFS=0/false/no/off turns it off
    captureContent?: boolean;   // default: true
}

function register(options?: RegisterOptions): void

Parameters

Parameter Type Default Description
endpoint str "https://traces.coalex.ai" Coalex proxy HTTP endpoint. Use the cloud default or your self-hosted proxy URL.
api_key str "dev" API key for proxy authentication. Use "dev" for local development with docker compose.
service_name str \| None "coalex-sdk-python" OpenTelemetry service name. Appears in the Coalex dashboard as the service identifier. It is not an agent id: see Which id an agent declares.
filter_non_ai_spans bool False When True, only AI/LLM spans are exported. HTTP, DB, and other non-AI spans are dropped before export.
suppress_export_errors bool True When True, export errors are logged but do not raise exceptions. Set to False for debugging connectivity issues.
declare AgentInventory \| list \| None None One declaration or a list, one per agent. See Declaring your agent.
genai_semconv bool \| None None When True, additively emit OTel GenAI attributes (gen_ai.input.messages / gen_ai.output.messages / gen_ai.system_instructions) derived from OpenInference messages. Defaults to the COALEX_GENAI_SEMCONV env var, or False when unset. See Semantic conventions. (Python SDK)
capture_content bool True When False, spans are exported without their content: prompts, responses, input.value / output.value, the gen_ai.* messages, tool arguments, retrieved documents and embedded text are removed from every span and span event, as are the keys Pydantic AI and Google ADK (gcp.vertex.agent.llm_request, llm_response, tool_call_args, tool_response, data) write their content under. The text of an exception (exception.message and exception.stacktrace on a span event, and the span's status description) is replaced by [removed]; the span keeps its ERROR status, its exception event and exception.type. Model, token counts, timing, status and the coalex.* attributes are kept. A span that cannot be stripped is not exported. Your other exporters on the same provider still receive the content. TypeScript: captureContent.
prompt_refs bool \| None None On by default: a prompt fetched with get_prompt() is exported as a reference to its Prompt Vault version instead of its text, and the platform restores the text when it stores the span. False, or COALEX_PROMPT_REFS set to 0, false, no or off, exports the text. See Prompt references. Defaults to the COALEX_PROMPT_REFS env var, or True when unset. Ignored with capture_content=False (TypeScript: captureContent: false). TypeScript: promptRefs, with the same default and the same off values.

All parameters are keyword-only (enforced by *).


Returns

None. The function configures global state as a side effect.


What it does

Calling register() performs the following setup:

  1. Creates an OpenTelemetry Resource with service.name set to service_name.
  2. Creates an OTLPSpanExporter pointing at {endpoint}/v1/traces with an Authorization: Bearer {api_key} header.
  3. Wraps the exporter in a SafeOTLPSpanExporter (suppresses transient network errors when suppress_export_errors=True).
  4. Optionally wraps in a ContentStrippingExporter (when capture_content=False; TypeScript: captureContent: false) that removes every content attribute before export, or else in a PromptReferenceExporter (when prompt_refs is on, the default; TypeScript: unless promptRefs: false) that replaces the text of vault prompts with a reference.
  5. Optionally wraps in a FilteringSpanExporter (when filter_non_ai_spans=True).
  6. Optionally wraps in a GenAISemconvExporter (when genai_semconv=True) that adds gen_ai.* message attributes at export time. With capture_content=False the attributes it adds are stripped too.
  7. Creates a TracerProvider with a CoalexAttributePropagator (copies coalex.* attributes from parent to child spans) and a BatchSpanProcessor.
  8. Sets the TracerProvider as the global OpenTelemetry tracer provider via trace.set_tracer_provider(), or, when one is already installed, adds the processors to it (see below).

Calling it twice, or after your own tracer setup

register() never accepts options it cannot apply. What happens depends on what is already installed:

Situation Python TypeScript
A second register() with the same options Nothing changes. Nothing changes.
A second register() with different options Raises RuntimeError naming each option that differs. The first registration stays in force. Throws an Error naming each option that differs. The first registration stays in force.
Your own tracer provider is already installed (Logfire, Datadog, opentelemetry-instrument, your own setup) The SDK adds its processors to that provider, so capture_content=False and the other options apply to what Coalex receives. The provider keeps its own resource, so service_name does not rename it, and your other exporters on it still receive every span unmodified. If that provider cannot take processors, register() raises, naming every option it would ignore. register() throws, naming every option it would ignore: OpenTelemetry's JavaScript SDK gives a provider no way to take another processor after it is built. Call register() before your own tracer setup, or pass a tracerProvider to autoInstrument().

Both SDKs refuse rather than ignore because the option that is lost silently is usually the privacy one: spans would keep their content and keep going to the first endpoint and key while evaluate() moved to the new configuration.

Sending spans before the process ends

Spans are batched for five seconds. Python flushes when the interpreter exits. TypeScript flushes when the process runs out of work (beforeExit), which covers a script that simply ends. Before process.exit(), or at the end of a serverless handler, await forceFlush() (send what has ended and keep exporting) or shutdown() (send it and stop; register() can install a fresh provider afterwards). (TypeScript SDK)


Examples

Cloud deployment

import os
import coalex

coalex.register(
    endpoint=os.environ["COALEX_ENDPOINT"],
    api_key=os.environ["COALEX_API_KEY"],
    service_name="claims-agent",
)
import { register } from "@coalex-ai/sdk";

register({
    endpoint: process.env.COALEX_ENDPOINT!,
    apiKey: process.env.COALEX_API_KEY!,
    serviceName: "claims-agent",
});

Local development

import coalex

coalex.register(
    endpoint="http://localhost:8080",
    api_key="dev",
    service_name="my-agent-dev",
)
import { register } from "@coalex-ai/sdk";

register({
    endpoint: "http://localhost:8080",
    apiKey: "dev",
    serviceName: "my-agent-dev",
});

Debugging export issues

import coalex

coalex.register(
    endpoint="https://traces.coalex.ai",
    api_key="your-api-key",
    service_name="my-agent",
    suppress_export_errors=False,  # raises on export failure
)
import { register } from "@coalex-ai/sdk";

register({
    endpoint: "https://traces.coalex.ai",
    apiKey: "your-api-key",
    serviceName: "my-agent",
    suppressExportErrors: false, // throws on export failure
});

Filtering non-AI spans

import coalex

coalex.register(
    endpoint="https://traces.coalex.ai",
    api_key="your-api-key",
    service_name="my-agent",
    filter_non_ai_spans=True,  # only export LLM/AI spans
)
import { register } from "@coalex-ai/sdk";

register({
    endpoint: "https://traces.coalex.ai",
    apiKey: "your-api-key",
    serviceName: "my-agent",
    filterNonAiSpans: true, // only export LLM/AI spans
});

Declaring your agent

By default the platform learns about your agent from its traces — it appears in the dashboard once it sends telemetry. That is enough to observe it, but not enough to talk to it: the Console needs to know where the agent lives before an operator can start a session with it.

Pass declare and the agent registers itself at startup:

import coalex
from coalex import AgentInventory

coalex.register(
    endpoint="https://traces.coalex.ai",
    api_key="your-api-key",
    service_name="my-agent",
    declare=AgentInventory(
        agent_id="my-agent",
        endpoint="https://my-agent.example.com/chat",
        tools=["lookup_portfolio", "wire_funds"],
        resume_url="https://my-agent.example.com/chat/resume",
    ),
)
import { register } from "@coalex-ai/sdk";

register({
    endpoint: "https://traces.coalex.ai",
    apiKey: "your-api-key",
    serviceName: "my-agent",
    declare: {
        agentId: "my-agent",
        endpoint: "https://my-agent.example.com/chat",
        tools: ["lookup_portfolio", "wire_funds"],
        resumeUrl: "https://my-agent.example.com/chat/resume",
    },
});

Declare with the same agent_id your coalex_context() uses, so the agent you declare and the agent your traces are attributed to are the same one. declare() (Python coalex.declare, TypeScript declare) sends a declaration after register(), with the endpoint and key register() configured.

Which id an agent declares

Python TypeScript Rule
agent_id agentId The agent's id. Omitted, it is the agent_id of the active coalex_context().
parent parent The agent this one proposes as its parent. Optional.
endpoint endpoint Where the Console POSTs a turn, an http(s) URL on a public address: a private, loopback, link-local or internal name is refused with a 422. Optional: a child reached only through its parent declares none.
resume_url resumeUrl Where the platform POSTs a decision to resume a paused run. An https URL on a public address: a private, loopback, link-local or internal name is refused with a 422, here and on evaluate()'s resume_hook.
name name Deprecated alias of agent_id: it warns once and is sent as the id. Removed in coalex 2.0 / @coalex-ai/sdk 3.0.

An agent's SDK key can declare a new agent and restate an existing one, but it cannot change that agent's endpoint or resume_url: the platform answers 403 and keeps the stored address. Moving an agent to a new address needs the organisation, which removes the agent's declaration so that the agent declares itself again.

When a declaration names no agent_id and no coalex_context() is active, the SDK uses the service_name you passed to register(), with a deprecation warning: that fallback is removed in coalex 2.0 / @coalex-ai/sdk 3.0. The default service names (coalex-sdk-python, coalex-sdk-typescript) name the SDK, not an agent, and are never used as an id: such a declaration is refused, with the reason logged, and nothing is sent.

The SDK checks an id before it sends anything, by the rule the platform applies: it must not be empty, start or end with whitespace, or be longer than 128 characters. Any character, . included, is allowed, and the shape of an id means nothing: a.b is not a child of a. A refused declaration is logged with its reason.

Declaring a multi-agent system

A system with an umbrella agent and workers declares each agent. The umbrella has the endpoint the Console talks to and the resume hook; each worker names the umbrella as its parent and declares no endpoint, because it is resumed through its nearest ancestor that has one. Each declaration is sent on its own, so one refusal does not stop the others.

import coalex
from coalex import AgentInventory

coalex.register(
    endpoint="https://traces.coalex.ai",
    api_key="your-api-key",
    service_name="claims-system",
    declare=[
        AgentInventory(
            agent_id="claims-umbrella",
            endpoint="https://claims.example.com/chat",
            resume_url="https://claims.example.com/chat/resume",
            tools=["route_claim"],
        ),
        AgentInventory(agent_id="claims-intake", parent="claims-umbrella", tools=["read_document"]),
        AgentInventory(agent_id="claims-payout", parent="claims-umbrella", tools=["wire_funds"]),
    ],
)
import { register } from "@coalex-ai/sdk";

register({
    endpoint: "https://traces.coalex.ai",
    apiKey: "your-api-key",
    serviceName: "claims-system",
    declare: [
        {
            agentId: "claims-umbrella",
            endpoint: "https://claims.example.com/chat",
            resumeUrl: "https://claims.example.com/chat/resume",
            tools: ["route_claim"],
        },
        { agentId: "claims-intake", parent: "claims-umbrella", tools: ["read_document"] },
        { agentId: "claims-payout", parent: "claims-umbrella", tools: ["wire_funds"] },
    ],
});

A proposed parent governs nothing until your organisation confirms it in the dashboard; a re-declaration without parent keeps the confirmed parent. The Console does not offer a child without an endpoint for a session.

How the agent introduces itself

A new chat in the Console opens with the agent introducing itself, from three optional fields (Python coalex 1.38.0, TypeScript @coalex-ai/sdk 2.1.0):

Python TypeScript Shown as
display_name displayName the title of the agent's first message (at most 200 characters)
description description the agent's first message (at most 4,000 characters)
suggested_prompts suggestedPrompts a button each, which sends exactly that text (at most 10, 500 characters each)
AgentInventory(
    agent_id="my-agent",
    endpoint="https://my-agent.example.com/chat",
    tools=["lookup_portfolio", "wire_funds"],
    display_name="Investment-advisory copilot",
    description="Hi, I help advisers look after their clients' portfolios.",
    suggested_prompts=["Review Acme Corp's portfolio", "Pay the invoice from Acme"],
)

The Console shows these as given and writes nothing of its own: an agent that declares none of them is shown by its name alone. Each declaration replaces the previous one whole, so a field the agent stops sending is cleared. Keep the text in one place in your agent, the same place its own greeting comes from, so the Console and the agent say the same thing.

What you can declare, and what you cannot

An agent declares where it lives, what tools it has, and how it introduces itself. It cannot declare whether those tools are governed, or who may approve them:

Who decides What
Your agent, here its id, its proposed parent, endpoint, resume URL, tool names, introduction
Your organisation, in the dashboard which tools require approval, and which role may approve

That separation is deliberate. If an agent could declare governed: false about its own tools, the thing being governed would be the author of its own governance — which is not governance at all, and not something you could defend to an auditor. Sending a policy field is rejected with a 422 rather than silently ignored.

Declared tools start ungoverned and the agent starts hidden from the Console until somebody in your organisation makes those calls. Declaring yourself is not the same as being chosen.

What each tool setting means

Your organisation sets one value per tool. What it does depends on how the call reaches Coalex: through the MCP Gateway or an HTTP pattern, or through evaluate() (a gate in your agent's code).

Setting Gateway and HTTP patterns evaluate()
Follows review policy (free) Runs Human review if forced review is on (the default); otherwise health sampling
Trust-based (governed) Runs once the agent's trust is at least high_trust_min; pauses below it Same as "Follows review policy": forced review, then health sampling. Trust is not read
Human review every time (always) Pauses Goes to a human, even with forced review off
Auto-approve (auto_approve) Runs Approved without a person, even with forced review on. Every such approval is written to the audit trail as decided by policy, naming the tool, the agent and the policy version
Same as parent agent (inherit) The confirmed parent's value, else "Follows review policy" The same

evaluate() reads the setting of the tool named by its tool_name argument only; a call that names no tool takes the org's forced review and health sampling. Only your organisation sets a value: an SDK key is refused on every policy write.


Notes

  • Declaring is best-effort. If the platform is unreachable at startup, the SDK logs a warning and carries on — your agent still starts and still traces, and the declaration is retried the next time it boots. A governance registration failing is never a reason for your agent to be down.
  • The SDK always sends traces through the proxy (via load balancer) so that authentication is validated in the request path. Never point endpoint directly at the collector.
  • The api_key is sent as a Bearer token in the Authorization header on every OTLP export request.
  • The BatchSpanProcessor buffers spans and exports them in batches asynchronously, so register() returns immediately.
  • If you need to use a custom TracerProvider (e.g., with additional processors or exporters), configure it yourself and pass it to auto_instrument(tracer_provider=...) instead of calling register().

API Reference

coalex.register

register(
    *,
    endpoint: str = "https://traces.coalex.ai",
    api_key: str = "dev",
    service_name: str | None = None,
    filter_non_ai_spans: bool = False,
    suppress_export_errors: bool = True,
    genai_semconv: bool | None = None,
    capture_content: bool = True,
    prompt_refs: bool | None = None,
    declare: AgentInventory
    | Sequence[AgentInventory]
    | None = None,
) -> None

Configure the Coalex OTLP exporter.

Call once at application startup. Sets the global TracerProvider with a BatchSpanProcessor exporting OTLP/HTTP to the Coalex proxy.

The SDK always sends through the proxy (via LB) so that auth validation is in the path. Never send directly to the collector.

Parameters:

Name Type Description Default
endpoint str

Coalex proxy HTTP endpoint (default: cloud).

'https://traces.coalex.ai'
api_key str

API key for proxy authentication (default: "dev" for local).

'dev'
service_name str | None

Service name for OTLP resource (default: "coalex-sdk-python"). It is not an agent id: a declaration without agent_id outside any coalex_context() falls back to it only when passed explicitly, a fallback deprecated and removed in coalex 2.0.

None
filter_non_ai_spans bool

If True, only export AI/LLM spans (default: False).

False
suppress_export_errors bool

If True, suppress export errors gracefully (default: True).

True
genai_semconv bool | None

If True, additively emit OTel GenAI attributes (gen_ai.input.messages / gen_ai.output.messages / gen_ai.system_instructions) derived from OpenInference's message attributes — the normalised shape the trace viewer and judge want. Defaults to the COALEX_GENAI_SEMCONV env var (1/true/yes/on), or False when unset.

None
capture_content bool

If False, export spans without their content: the prompts, the responses, input.value/output.value, the gen_ai.* messages, tool arguments, retrieved documents and embedded text are removed from every span and span event before export (see :mod:coalex.content_capture), as are the keys Pydantic AI and Google ADK write their content under. The text of an exception (exception.message and exception.stacktrace on a span event, and the span's status description) is replaced by [removed]; the span keeps its ERROR status, its exception event and exception.type. Model, token counts, timing, status and the coalex.* attributes are kept. Default True: content is exported.

True
prompt_refs bool | None

If True (the default), the text of a prompt fetched with :func:get_prompt is exported as a reference to its Prompt Vault version (coalex.prompt.ref) instead of in full, and the platform restores it from the vault when it stores the span (see :mod:coalex.prompt_refs): traces are smaller and carry the same governance data. Pass False, or set COALEX_PROMPT_REFS to 0/false/no/off, to export the text as before. Defaults to the COALEX_PROMPT_REFS env var, or True when unset. Ignored when capture_content is False, which exports no prompt text at all.

None
declare AgentInventory | Sequence[AgentInventory] | None

Optionally tell the platform where this agent lives and what tools it has, so it appears in the Console without anyone registering it by hand. One :class:AgentInventory or a list of them, one per agent of a multi-agent system; each is sent on its own, so one refusal does not stop the others. Best-effort: a failure is logged and startup continues. Omit it and register() makes no network call at all, exactly as before. :func:declare does the same after register().

None

Raises:

Type Description
RuntimeError

The options cannot take effect. Either register() was called before with different span options (the exporter chain is built once; the message names each option that differs), or the application's own tracer provider cannot take span processors (the message names each option that would be ignored).

A tracer provider the application installed first (its own OpenTelemetry setup, Logfire, opentelemetry-instrument) is kept: the SDK adds its attribute processor and its exporter to it, so capture_content and the other options apply to what Coalex receives. The application's other exporters on that provider still see every span unmodified, and the provider keeps its own resource, so service_name does not rename it.

Source code in coalex/__init__.py
def register(
    *,
    endpoint: str = "https://traces.coalex.ai",
    api_key: str = "dev",
    service_name: str | None = None,
    filter_non_ai_spans: bool = False,
    suppress_export_errors: bool = True,
    genai_semconv: bool | None = None,
    capture_content: bool = True,
    prompt_refs: bool | None = None,
    declare: AgentInventory | Sequence[AgentInventory] | None = None,
) -> None:
    """Configure the Coalex OTLP exporter.

    Call once at application startup. Sets the global TracerProvider
    with a BatchSpanProcessor exporting OTLP/HTTP to the Coalex proxy.

    The SDK always sends through the proxy (via LB) so that auth
    validation is in the path. Never send directly to the collector.

    Args:
        endpoint: Coalex proxy HTTP endpoint (default: cloud).
        api_key: API key for proxy authentication (default: "dev" for local).
        service_name: Service name for OTLP resource (default: "coalex-sdk-python"). It is not an
            agent id: a declaration without ``agent_id`` outside any ``coalex_context()`` falls
            back to it only when passed explicitly, a fallback deprecated and removed in coalex 2.0.
        filter_non_ai_spans: If True, only export AI/LLM spans (default: False).
        suppress_export_errors: If True, suppress export errors gracefully (default: True).
        genai_semconv: If True, additively emit OTel GenAI attributes
            (``gen_ai.input.messages`` / ``gen_ai.output.messages`` / ``gen_ai.system_instructions``)
            derived from OpenInference's message attributes — the normalised shape the trace
            viewer and judge want. Defaults to the ``COALEX_GENAI_SEMCONV`` env var
            (``1``/``true``/``yes``/``on``), or False when unset.
        capture_content: If False, export spans without their content: the prompts, the
            responses, ``input.value``/``output.value``, the ``gen_ai.*`` messages, tool arguments,
            retrieved documents and embedded text are removed from every span and span event
            before export (see :mod:`coalex.content_capture`), as are the keys Pydantic AI and
            Google ADK write their content under. The text of an exception (``exception.message``
            and ``exception.stacktrace`` on a span event, and the span's status description) is
            replaced by ``[removed]``; the span keeps its ERROR status, its ``exception`` event and
            ``exception.type``. Model, token counts, timing, status and the ``coalex.*``
            attributes are kept. Default True: content is exported.
        prompt_refs: If True (the default), the text of a prompt fetched with :func:`get_prompt` is
            exported as a reference to its Prompt Vault version (``coalex.prompt.ref``) instead of
            in full, and the platform restores it from the vault when it stores the span (see
            :mod:`coalex.prompt_refs`): traces are smaller and carry the same governance data. Pass
            False, or set ``COALEX_PROMPT_REFS`` to ``0``/``false``/``no``/``off``, to export the
            text as before. Defaults to the ``COALEX_PROMPT_REFS`` env var, or True when unset.
            Ignored when ``capture_content`` is False, which exports no prompt text at all.
        declare: Optionally tell the platform where this agent lives and what tools it has,
            so it appears in the Console without anyone registering it by hand.
            One :class:`AgentInventory` or a list of them, one per agent of a multi-agent system;
            each is sent on its own, so one refusal does not stop the others. Best-effort: a
            failure is logged and startup continues. Omit it and ``register()`` makes no network
            call at all, exactly as before. :func:`declare` does the same after ``register()``.

    Raises:
        RuntimeError: The options cannot take effect. Either ``register()`` was called before with
            different span options (the exporter chain is built once; the message names each option
            that differs), or the application's own tracer provider cannot take span processors
            (the message names each option that would be ignored).

    A tracer provider the application installed first (its own OpenTelemetry setup, Logfire,
    ``opentelemetry-instrument``) is kept: the SDK adds its attribute processor and its exporter to
    it, so ``capture_content`` and the other options apply to what Coalex receives. The
    application's other exporters on that provider still see every span unmodified, and the
    provider keeps its own resource, so ``service_name`` does not rename it.
    """
    global _config, _api_key, _explicit_service_name, _registered_provider, _registered_options

    import os

    from opentelemetry import trace
    from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
        OTLPSpanExporter,
    )
    from opentelemetry.sdk.resources import Resource
    from opentelemetry.sdk.trace import TracerProvider
    from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter

    from coalex.content_capture import ContentStrippingExporter
    from coalex.filtering_exporter import FilteringSpanExporter
    from coalex.genai_semconv import GenAISemconvExporter
    from coalex.prompt_refs import PromptReferenceExporter
    from coalex.safe_exporter import SafeOTLPSpanExporter
    from coalex.span_processor import CoalexAttributePropagator

    if genai_semconv is None:
        genai_semconv = os.environ.get("COALEX_GENAI_SEMCONV", "").strip().lower() in ("1", "true", "yes", "on")
    if prompt_refs is None:
        prompt_refs = os.environ.get("COALEX_PROMPT_REFS", "").strip().lower() not in ("0", "false", "no", "off")

    explicit_service_name = service_name
    if service_name is None:
        service_name = _DEFAULT_SERVICE_NAME

    options: dict[str, Any] = {
        "endpoint": endpoint,
        "api_key": api_key,
        "service_name": service_name,
        "filter_non_ai_spans": filter_non_ai_spans,
        "suppress_export_errors": suppress_export_errors,
        "genai_semconv": genai_semconv,
        "capture_content": capture_content,
        "prompt_refs": prompt_refs,
    }

    # Decide against the provider that is already installed BEFORE anything is stored: a refused
    # call must leave the first registration exactly as it was, so evaluate() and the spans keep
    # agreeing on one endpoint and key.
    existing = trace.get_tracer_provider()
    ours_already = _registered_provider is not None and existing is _registered_provider
    foreign = not ours_already and not isinstance(existing, trace.ProxyTracerProvider | trace.NoOpTracerProvider)
    if ours_already:
        registered = _registered_options or {}
        changed = [name for name, value in options.items() if registered.get(name) != value]
        if changed:
            raise RuntimeError(
                "coalex.register() was already called with different options: "
                + ", ".join(changed)
                + ". The span exporter is built once, so these cannot take effect on spans. "
                "Call register() once, or call it again with the same options."
            )
    elif foreign and not hasattr(existing, "add_span_processor"):
        raise RuntimeError(
            f"A tracer provider is already installed ({type(existing).__name__}) and it cannot take "
            "span processors, so coalex.register() cannot send spans through it. These options "
            "would be ignored: " + ", ".join(options) + ". Install the Coalex "
            "span processor in that provider yourself, or call register() before it is set up."
        )

    _explicit_service_name = explicit_service_name
    _config = CoalexConfig(endpoint=endpoint, service_name=service_name)
    _api_key = api_key

    resource = Resource({"service.name": service_name})

    base_exporter = OTLPSpanExporter(
        endpoint=f"{_config.endpoint}/v1/traces",
        headers=sdk_headers(api_key),
    )

    # Wrap exporter chain (innermost first): OTLP -> content strip | prompt references -> Safe ->
    # Filtering -> GenAI bridge. The GenAI bridge sits outermost so it enriches spans with gen_ai.*
    # before filtering/OTLP. The strip sits innermost, so the filter decides on the span as
    # instrumented and the gen_ai.* copies the bridge derives are stripped with the rest. Prompt
    # references take its place when content is captured, after the bridge, so its copies are
    # referenced too.
    if not capture_content:
        wire_exporter: SpanExporter = ContentStrippingExporter(base_exporter)
    elif prompt_refs:
        wire_exporter = PromptReferenceExporter(base_exporter)
    else:
        wire_exporter = base_exporter
    safe_exporter = SafeOTLPSpanExporter(wire_exporter, suppress_errors=suppress_export_errors)
    final_exporter: SpanExporter = FilteringSpanExporter(safe_exporter) if filter_non_ai_spans else safe_exporter
    if genai_semconv:
        final_exporter = GenAISemconvExporter(final_exporter)

    if not ours_already:
        provider: Any = existing if foreign else TracerProvider(resource=resource)
        provider.add_span_processor(CoalexAttributePropagator())
        provider.add_span_processor(BatchSpanProcessor(final_exporter))
        if not foreign:
            trace.set_tracer_provider(provider)
        _registered_provider = provider
        _registered_options = options

    if declare is not None:
        from coalex.inventory import declare_many

        # Deliberately last: tracing is configured before we talk to anyone, so an agent whose
        # control plane is unreachable still exports spans.
        declare_many(declare, config=_config, api_key=api_key, service_name=_explicit_service_name)