> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hue.run/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> To set up Hue in an application, fetch https://docs.hue.run/guides/agent-setup.md and follow it.
> Hue's project-data MCP server is https://mcp.hue.run/mcp. https://docs.hue.run/mcp only searches these docs and cannot read a Hue project.
> Never ask the user to paste a Hue API key into chat; they store it as HUE_API_KEY themselves.
> Install the exact SDK versions pinned on the page you're following, or on https://docs.hue.run/quickstart.md. Run the latest CLI without a project install as `npx -y @hue-run/sdk@latest`. In a project with `@hue-run/sdk` installed, use `npx hue`. Pin an exact version for reproducible CI runs.
> Use Hue product terms: eval set, case, evaluator, run and scoring run. Older APIs may say dataset, scorer or experiment; prefer the current SDK methods. See https://docs.hue.run/concepts.md.

# Media

> Record images, audio and documents in traces, check that their bytes reached Hue, and read them back.

Hue stores the bytes of the images, audio and documents your spans record apart from the spans themselves, named by SHA-256. The span keeps each file's digest where the file was, and lists its uploads in the `hue.blobs` attribute. Open the span in **Traces** to see the files: a file inside a message appears in place, other files appear below the messages, and values uploaded whole appear under **Large values**.

Uploads need content capture and an ordinary project key. With metadata-only capture or a setup credential (`hue_setup_…`), the SDKs upload nothing. Your redaction callback runs first: nothing it removes is uploaded, and a value it rewrites uploads as rewritten. A text pattern cannot see inside a file's encoded bytes, so to keep personal data in documents out of Hue, drop or replace the file part in the callback. See [Data privacy](/guides/redaction).

## How files reach Hue

| You have | Use | The span records |
| - | - | - |
| Files inline in messages and values your code or framework already records | Nothing: [automatic capture](#automatic-capture) | The part in digest form, keyed `<attribute>#<JSON pointer>` |
| A file to place at a position in a value you record | [`Attachment`](#attach-a-file) | A GenAI `blob` part in digest form, keyed `<attribute>#<JSON pointer>` |
| A file the work read, received or produced, outside any recorded value | [`recordFile` / `record_file`](#record-a-file) with upload on | A `hue.file` event, keyed `event:hue.file:<sha256>` |
| An exporter that writes OTLP without a Hue SDK | [The upload API](#attach-files-without-the-sdk) | A `hue.file` event or digest part you write |
| Files a case's agent and evaluators receive | [Case files](#files-in-cases) | Files pinned on the case, not in a trace |

## What the trace view shows

| File | In the trace view |
| - | - |
| PNG, JPEG, GIF, WebP and AVIF images | Shown inline |
| MP3, WAV, Ogg, Opus, WebM, MP4, AAC and FLAC audio | Played with the browser's controls |
| Raw 16-bit PCM whose media type declares its rate, and OpenAI `pcm16` audio | Played, up to 32 MiB; Hue adds a WAV header in the browser and leaves the stored bytes as sent |
| MP4 and WebM video | Played with the browser's controls |
| PDF | Opens in a new tab |
| Text documents | Shown as text; an uploaded text file opens with **Preview** under **Large values** |
| SVG, HTML, Word documents and other types | Download only |
| Remote URL | A web URL is shown as a link; Hue never loads it |

Raw PCM plays when its media type names its sample rate: `audio/L16;rate=24000` (big-endian, as RFC 2586 defines it) or `audio/pcm;rate=16000;channels=1` (little-endian), with a whole `rate` from 8000 to 192000 and 1 or 2 `channels`. OpenAI audio declared `pcm16` plays as 24 kHz mono. Raw PCM without a declared rate is download only. Playback depends on the browser's support for the format.

A file listed in `hue.blobs` also shows a state: **Stored**, **Not uploaded** (not uploaded yet, never uploaded, or erased) or **Unavailable** (Hue could not read the storage).

## Automatic capture

Hue recognizes files in these recorded forms, so images, audio and documents that OpenInference, OpenLLMetry or other instrumentation records inline appear without Hue code:

* Message parts in the OpenTelemetry GenAI (`blob`, `uri` and `file`), Vercel AI SDK, OpenAI Chat Completions and Responses, Anthropic, Gemini, Vertex AI and Amazon Bedrock Converse formats, including OpenAI assistant audio and Responses computer screenshots.
* MCP tool results' `image` and `audio` blocks, and embedded `resource` blocks that hold bytes (`blob`), named by the last segment of the resource's URI. A text resource stays text.
* Pydantic AI `binary` parts, `image-url`, `audio-url`, `video-url` and `document-url` parts, and a tool's `BinaryContent` return.
* LangChain `image`, `audio`, `video` and `file` content blocks with `base64` data, a `url` or a provider `file_id`, in LangChain v1's standard form or the earlier `source_type` form.
* OpenInference's flattened image contents and OpenLLMetry's JSON-encoded multipart prompts.
* A whole `data:` URL that declares its media type, anywhere in recorded input, output, tool arguments, tool results or metadata, except as the `uri` of a GenAI `uri` part. Hue treats a `uri` part as a reference and doesn't read or upload a `data:` URL there; put inline bytes in a `blob` part instead.

With content capture on, the Hue SDKs also upload inline files from these span attributes and leave each file's digest in its place:

* **Messages and values:** `gen_ai.input.messages`, `gen_ai.output.messages`, `ai.prompt.messages`, `ai.prompt`, `input.value` and `output.value` (what `withSpan` / `span()` records), `traceloop.entity.input` and `traceloop.entity.output`, and Langfuse's `langfuse.observation.input`, `langfuse.observation.output`, `langfuse.trace.input` and `langfuse.trace.output`.
* **Tool arguments and results:** `gen_ai.tool.call.arguments`, `gen_ai.tool.call.result`, `ai.toolCall.args`, `ai.toolCall.result`, `tool.parameters`, `tool_arguments` and `tool_response`.
* **Metadata:** `metadata`, `langfuse.observation.metadata` and `langfuse.trace.metadata` (whole, or one `.<key>` per attribute), and `ai.telemetry.metadata.<key>`.
* **Indexed messages:** OpenLLMetry's `gen_ai.prompt.<n>.content` and `gen_ai.completion.<n>.content` when the value is the JSON of a parts list, and OpenInference's flattened `llm.input_messages.<n>.message.contents.<m>.message_content.image.image.url` and its `llm.output_messages` counterpart.

AI SDK 6 records an image passed as a `Uint8Array` in `ai.prompt` as an object of numbered bytes, which is not read as a file and uploads as a value many times the image's size; pass image data to the AI SDK as a base64 string or `data:` URL instead.

Inside those values the SDKs read GenAI `blob` parts, AI SDK `file` and `image` parts, OpenAI Chat image, file and audio parts and an assistant message's `audio`, Responses `input_image`, `input_file` and `computer_screenshot`, Anthropic base64 sources, Gemini `inlineData` and `inline_data`, Vertex `media`, Bedrock image and document sources, and any whole string that is a `data:` URL declaring a media type. Files inside a tool call's JSON-encoded `arguments` and a tool result's JSON string are found too.

Each uploaded file takes one of these forms; the rest of the recorded JSON text is kept byte for byte:

* A provider part loses its byte field and carries `sha256` and `size`, keyed at the part, for example `gen_ai.input.messages#/0/parts/1`. An OpenAI assistant message's `audio` loses `audio.data`, and the message carries the digest.
* Raw 16-bit PCM keeps the format its type declares: `audio/pcm`, `audio/x-pcm` or `audio/L16` with a `rate` is listed as, for example, `audio/pcm;rate=24000;channels=1`, and OpenAI `pcm16` audio as `audio/pcm;rate=24000;channels=1`.
* A whole `data:` URL string becomes a GenAI `blob` part, `{"type":"blob","mime_type":"image/png","modality":"image","sha256":"<64 hex>","size":68}`, keyed at the string. A value that is entirely one, such as the string input or output Langfuse's SDK records as it is, becomes that part's JSON text, keyed `<attribute>#`. A text data URL up to 64 KiB stays inline.
* A file inside a JSON-encoded tool string takes the digest form inside the string. No JSON pointer can address inside a string, so Hue matches it by digest.
* Text media over 64 KiB of decoded bytes keeps a UTF-8-safe 16 KiB prefix in its content field, keyed at that field, for example `gen_ai.input.messages#/0/parts/1/content`. A text file that a GenAI `blob`, Vertex `media`, or AI SDK `file` or `image` part holds as bare base64 is decoded first: over 64 KiB of decoded bytes, it uploads and takes the digest form at the part, like binary media.

A span lists at most 64 files in `hue.blobs`: its inline media first, in reading order, then its attachments and recorded files. An entry's `name` comes from the part's file name, name or title when it is valid: nonblank after trimming, at most 256 UTF-16 code units and well-formed Unicode, with no C0 control characters (U+0000–U+001F) or DEL (U+007F). Data URLs and base64 fields are decoded before hashing, so a part's digest matches the digest of the same bytes recorded another way. Another string with a declared textual media type is hashed as UTF-8.

The SDKs leave a file as recorded when it is more than ten levels below a value's root, when attribute JSON is nested past 32 levels or holds an integer past 2^53 (Hue keeps such JSON as text), and for an OpenInference `input.value` or `output.value` whose record declares it `text/plain`. A span's own attribute too large to parse, such as a model call carrying minutes of base64 audio, is still read for files: each base64 or `data:` URL string in it that is longer than 16 Ki code units and written without escapes is decoded, hashed and uploaded a 1 MiB slice at a time, up to 1 GB, as long as the rest of the value's JSON fits within 8 Mi code units.

Log records correlated with a span upload their media the same way: the inference-details record `recordMessages()` / `log_inference()` writes, and any `gen_ai.*` event with a structured body. The log record lists its files in its own `hue.blobs`, keyed `body#<JSON pointer>`, for example `body#/gen_ai.input.messages/0/parts/1`. A log record that cannot upload, such as one not correlated with a span, keeps the earlier rule: a file over 64 KiB in a recorded message becomes its digest, and other files keep their bytes. Resources, links and span events never upload; in their recorded messages, media up to 64 KiB stays as recorded and larger media becomes its digest.

## Attach a file

Wrap a file in an `Attachment` and put it in any value Hue records: a span's input or output, model messages, a tool's arguments or result, inference-log messages, or a span attribute.

**TypeScript**

```ts theme={null}
import { readFile } from "node:fs/promises";
import { Attachment } from "@hue-run/sdk";

const png = await readFile("scan.png");
await hue.withSpan(
  "review contract",
  (span) => {
    span.setInput({
      question: "Which clauses changed?",
      contract: new Attachment({ path: "contract.pdf", contentType: "application/pdf" }),
    });
  },
  { attributes: { metadata: { scan: new Attachment({ data: png, contentType: "image/png" }) } } },
);
```

**Python**

```python theme={null}
from pathlib import Path

from hue_sdk import Attachment

png = Path("scan.png").read_bytes()
with hue.span(
    "review contract",
    attributes={"metadata": {"scan": Attachment(png, content_type="image/png")}},
) as span:
    span.set_input(
        {
            "question": "Which clauses changed?",
            "contract": Attachment(path="contract.pdf", content_type="application/pdf"),
        }
    )
```

Give exactly one of `data` (bytes, or a base64 string or `data:` URL) and `path`, a content type and an optional name. `data`, limited to 25 MiB, and a file up to 25 MiB given by `path` are read and hashed when the attachment is created and stay in memory until they upload, within the 128 MiB the SDK holds for values awaiting upload. A larger file given by `path`, up to 1 GB, is hashed when the attachment is created and streamed from disk when it uploads. Keep that file in place and unchanged until the client flushes: a file that changed or is gone by then is not uploaded.

At its position the attachment is recorded as a GenAI `blob` part in digest form, `{"type":"blob","mime_type":"application/pdf","modality":"document","sha256":"<64 hex>","size":48213,"name":"contract.pdf"}`, keyed `<attribute>#<JSON pointer to the part>`: `input.value#/contract` and `metadata#/scan` above. Put files under `metadata` for Hue to list them as the span's metadata files. A value still over the 1 MiB inline limit with its attachments as digest parts uploads whole and keeps only its first 16 KiB inline; the attachments' bytes are stored and can be [read back](#read-files-back) by digest, but are not placed at their positions in the value.

Creating an attachment never fails. One without exactly one of `data` and `path`, with unreadable or oversized data, or with an invalid content type is recorded as `null` (`None` in Python) and counted as an instrumentation failure. An invalid name alone is omitted and counted.

## Record a file

To record a file your application reads or produces outside any value, such as the audio of a voice turn, record it on its span:

**TypeScript**

```ts theme={null}
await hue.withSpan("voice turn", async () => {
  hue.recordFile({ role: "input", mediaType: "audio/wav", path: "turns/0042-user.wav", name: "user.wav", upload: true });
  hue.recordFile({ role: "output", mediaType: "audio/wav", path: "turns/0042-reply.wav", name: "reply.wav", upload: true });
});
```

**Python**

```python theme={null}
with hue.span("voice turn") as span:
    span.record_file(role="input", media_type="audio/wav", path="turns/0042-user.wav", name="user.wav", upload=True)
    span.record_file(role="output", media_type="audio/wav", path="turns/0042-reply.wav", name="reply.wav", upload=True)
```

`role` is `input`, `attachment` or `output`. Give the file as `data` (bytes, up to 25 MiB, held in memory until it uploads) or `path` (a file on disk, up to 1 GB; over 25 MiB it streams from disk when it uploads). `upload` defaults to off: without it, the `hue.file` event records the digest, role, media type and size, but no bytes. Record audio in a format the trace view plays, such as WAV, MP3 or Opus; [wrap raw PCM in a WAV header](#attach-a-voice-turn) first.

Large files need time to upload. In TypeScript, each upload ends within six export timeouts plus 60 seconds of being queued: two minutes at the default `timeoutMillis` of 10,000, so a 1 GB file needs at least about 8.3 MB/s; raise `timeoutMillis` (up to 60,000) for slower links. In Python, each `PUT` attempt has six export timeouts or one second per MiB, whichever is longer.

## Upload behavior

A span or log record is exported at once with its file references, and the bytes upload in the background, four at a time, so an upload never delays or drops a record. Each reservation and `PUT` (and, in Python, the completion) is tried up to three times on no answer, 408, 429 or 5xx, waiting `Retry-After` (up to 60 seconds) or a short backoff. A file used more than once in a trace is stored once.

* A file refused when its record is exported, because the receiver has no upload route, uploads were ended by shutdown or a budget ran out, keeps its digest without a `hue.blobs` entry in `gen_ai.input.messages`, `gen_ai.output.messages` and `ai.prompt.messages`; in any other attribute it keeps its bytes inline, under the 1 MiB value limit.
* A file whose upload fails after its record was exported keeps its digest and its entry, and shows **Not uploaded** until the same bytes are uploaded under the trace.
* A span's own string attribute over the inline limit uploads whole, up to 1,000,000,000 bytes, and keeps a 16 KiB prefix inline.
* A file that cannot be uploaded never fails the export. It counts in `uploadFallbacks` in `hue.transport.getReport()` (TypeScript) or `hue.export_status.upload_fallbacks` (Python); successful uploads count in `uploadedValues` / `uploaded_values`.

Flush or shut down with a budget that covers your uploads: `flush()` and `force_flush()` send records first, then wait for the uploads queued before them, and a shutdown whose budget runs out ends the uploads still pending. In restricted-egress environments, allow HTTPS `PUT` to the upload URLs Hue returns.

## Remote references

Hue stores only bytes that reach it. Neither the SDKs nor Hue fetch a remote URL, a provider file ID, an S3 reference or a file another service holds, so these stay as recorded with no bytes behind them. The trace view shows a web URL as a link, and a Langfuse media reference (`@@@langfuseMedia:type=…|id=…|source=…@@@`) with its media type as **Held in Langfuse**, with nothing to open or download. MCP `get_span` lists them with their `uri`, `file_id` or `langfuse_media_id`.

A case built from a trace has nothing to copy for these files, and judges cannot see them. To keep a file in Hue, record its bytes inline, as an attachment or with a recorded file.

With Langfuse's TypeScript SDK and Hue's processor [beside Langfuse's](/integrations/opentelemetry#use-with-langfuse), Hue records the span before Langfuse replaces the file, so a `data:` URL or `LangfuseMedia` uploads to Hue in the background with no change to your code. Langfuse's Python SDK replaces the file when the observation is set, so Hue's copy holds the reference. For files already in Langfuse, run [`hue import langfuse --media`](/sdks/cli#import-traces-from-langfuse) with the Langfuse project's keys.

## Attach files without the SDK

An exporter that writes OTLP itself can record a small file inline, as a `data:` URL or base64 part, within the 1 MiB value limit. To attach a larger file, up to 1,000,000,000 bytes (1 GB), upload it apart from the span with the key that sends the trace (**Tracing only** in production):

1. Reserve the upload with `POST https://app.hue.run/api/v1/otlp/blobs` and a JSON body of `traceId` (the span's 32-hex trace ID), `sha256` (the lowercase hex SHA-256 of the bytes), `byteSize`, `contentType` (the media type) and `key` (the file's `hue.blobs` key, below). If Hue already holds the bytes for this trace, it answers `{"status": "exists", "ref": …}`; go to step 4. Otherwise it answers `{"status": "upload", "url": …, "method": "PUT", "headers": …, "expiresAt": …, "ref": …}`.
2. Send the bytes with `PUT` to `url` within 15 minutes, with exactly the returned `headers` and no `Authorization` header. A `412` answer means the bytes are already stored.
3. Complete the upload with `POST https://app.hue.run/api/v1/otlp/blobs/complete` and a JSON body of `traceId` and `sha256`. Hue checks the stored bytes against the reservation.
4. Name the file on a span of that trace and list it in the span's `hue.blobs` attribute. The span can reach Hue before or after the upload, so you can export it first and upload the file afterwards. Retry a 429, a 5xx or a network failure a few times, never sooner than its `Retry-After`. Hue shows the file once its bytes land.

Name the file in one of two forms:

* **A `hue.file` event**, for a file the work read, received or produced: `hue.file.sha256`, `hue.file.size`, `hue.file.media_type`, `hue.file.role` (`input`, `attachment` or `output`) and an optional `hue.file.name`. Its key is `event:hue.file:<sha256>`, and the file appears with the span's other files.
* **A message part in digest form**, for a file inside recorded messages: the part carries `sha256` and `size` in place of its content, for example `{"type": "blob", "modality": "image", "mime_type": "image/png", "sha256": "<64 hex>", "size": 48213}`. Its key is the attribute and the part's JSON pointer, such as `gen_ai.input.messages#/0/parts/1`, and the file appears in place in the message.

`hue.blobs` is a string-array span attribute with one compact JSON entry per file: `key`, `sha256`, `size`, `content_type` and an optional `name` of 1 to 256 characters. The reservation's `ref` is that entry without `name`. Hue reads up to 64 entries per span, one per key, and ignores an entry it cannot parse.

With the packages from the [OpenTelemetry Python example](/integrations/opentelemetry#send-from-any-language), save this as `attach.py` next to a `chart.png` and run `python attach.py`:

```python theme={null}
import hashlib
import json
import os
import urllib.error
import urllib.request

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

HUE = "https://app.hue.run"
api_key = os.environ["HUE_API_KEY"]  # Raises when unset; keep the key in the server environment.


def post(path, body):
    request = urllib.request.Request(
        HUE + path,
        data=json.dumps(body).encode(),
        method="POST",
        headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
    )
    with urllib.request.urlopen(request, timeout=60) as response:
        return json.load(response)


def attach_file(span, data, media_type, name, role="input"):
    """Uploads a file under the span's trace, records its hue.file event and returns its hue.blobs entry."""
    trace_id = format(span.get_span_context().trace_id, "032x")
    sha256 = hashlib.sha256(data).hexdigest()
    reserved = post("/api/v1/otlp/blobs", {
        "traceId": trace_id,
        "sha256": sha256,
        "byteSize": len(data),
        "contentType": media_type,
        "key": f"event:hue.file:{sha256}",
    })
    if reserved["status"] == "upload":
        put = urllib.request.Request(reserved["url"], data=data, method="PUT", headers=reserved["headers"])
        try:
            urllib.request.urlopen(put, timeout=300).close()
        except urllib.error.HTTPError as error:
            if error.code != 412:  # 412: the bytes are already stored.
                raise
        post("/api/v1/otlp/blobs/complete", {"traceId": trace_id, "sha256": sha256})
    span.add_event("hue.file", {
        "hue.file.sha256": sha256,
        "hue.file.size": len(data),
        "hue.file.media_type": media_type,
        "hue.file.role": role,
        "hue.file.name": name,
    })
    return json.dumps({**reserved["ref"], "name": name}, separators=(",", ":"))


provider = TracerProvider(resource=Resource.create({"service.name": "file-demo"}))
provider.add_span_processor(
    BatchSpanProcessor(
        OTLPSpanExporter(
            endpoint=f"{HUE}/api/v1/otlp/v1/traces",
            headers={"Authorization": f"Bearer {api_key}"},
        )
    )
)
tracer = provider.get_tracer("example.instrumentation")

with tracer.start_as_current_span("agent.request") as span:
    with open("chart.png", "rb") as file:
        entry = attach_file(span, file.read(), "image/png", "chart.png")
    span.set_attribute("hue.blobs", [entry])  # Every file the span names, in one list.
    print("Trace ID:", format(span.get_span_context().trace_id, "032x"))
provider.shutdown()  # Flushes the batch processor and the exporter.
```

### Attach a voice turn

Attach a voice turn's audio the same way: one `attach_file` call per clip, with every entry in the span's `hue.blobs`. Raw PCM, which realtime voice APIs stream, carries no header that names its sample rate: declare it in the media type, as [above](#what-the-trace-view-shows), or wrap it in a WAV header first. Python's `wave` module does that without re-encoding. In `attach.py`, in place of the `agent.request` span:

```python theme={null}
import io
import wave


def wav_from_pcm16(pcm, rate=24000, channels=1):
    """Wraps 16-bit little-endian PCM, such as OpenAI's pcm16 (24 kHz mono), in a WAV header."""
    buffer = io.BytesIO()
    with wave.open(buffer, "wb") as wav:
        wav.setnchannels(channels)
        wav.setsampwidth(2)
        wav.setframerate(rate)
        wav.writeframes(pcm)
    return buffer.getvalue()


with open("user.pcm", "rb") as file:
    user_pcm = file.read()
with open("reply.pcm", "rb") as file:
    reply_pcm = file.read()

with tracer.start_as_current_span("voice.turn") as span:
    span.set_attribute("hue.blobs", [
        attach_file(span, wav_from_pcm16(user_pcm), "audio/wav", "user.wav", role="input"),
        attach_file(span, wav_from_pcm16(reply_pcm), "audio/wav", "reply.wav", role="output"),
    ])
provider.shutdown()
```

### Upload errors

The upload calls answer errors as JSON `{"error": …}`:

| Status | Meaning |
| - | - |
| `400` | A malformed body or field. `traceId` is 32 hex characters and not all zeros, `sha256` is 64 hex characters, `byteSize` is a whole number from 1, `contentType` is a media type, and `key` has 1 to 256 characters and no control characters. |
| `401` | The key is missing, unknown, revoked or expired. |
| `403` | The key cannot send traces, such as a **Read** key, or is a setup credential, which cannot upload. |
| `404` | The project is archived, or a completion names a value that was never reserved. |
| `409` | `"status": "size"`: the digest was reserved with another size. `"status": "missing"`: the completion came before the upload. `"status": "differs"`: the stored object does not match the reservation. |
| `413` | A file over 1 GB, or a request body over 4 KiB. |
| `415` | The body is not `application/json`. |
| `429` | The project's per-minute limit is reached, or the key failed authentication too often; retry after `Retry-After`. |
| `503` | Hue is busy or storage is unavailable; retry after `Retry-After`. |

## Read files back

An uploaded file is recorded content: it follows its trace's [retention](/guides/redaction#retention), and reading it needs a **Read** or **Read and write** key, or a Hue MCP connection with `include_content: true`.

**TypeScript**

```ts theme={null}
import { createEvaluationClient } from "@hue-run/sdk/evals";

const client = createEvaluationClient({ apiKey: process.env.HUE_API_KEY! });

// One file by digest; pass the span that recorded it when you know it.
const file = await client.media.fetch({ traceId, spanId, sha256 });
console.log(file.contentType, file.fileName, file.size, file.bytes.length);

// Every digest part of a recorded value, written back in its own field.
const recorded = JSON.parse(attributes["gen_ai.input.messages"]);
const { value: messages, failures } = await client.media.resolve(recorded, { traceId, spanId });
```

**Python**

```python theme={null}
import json
import os

from hue_sdk.evals import EvaluationClient

client = EvaluationClient(api_key=os.environ["HUE_API_KEY"])

# One file by digest; pass the span that recorded it when you know it.
file = client.media.fetch(trace_id=trace_id, span_id=span_id, sha256=sha256)
print(file.content_type, file.file_name, file.size, len(file.data))

# Every digest part of a recorded value, written back in its own field.
recorded = json.loads(attributes["gen_ai.input.messages"])
resolved = client.media.resolve(recorded, trace_id=trace_id, span_id=span_id)
messages, failures = resolved.value, resolved.failures
```

`fetch` asks Hue for a five-minute download link, downloads it without the project key and refuses bytes whose SHA-256 is not the digest. A failure raises `MediaFetchError` with a `reason`: `not_found`, `missing` (listed but never uploaded, or erased), `unavailable`, `forbidden` (the key cannot read recorded content; a snapshot's copy needs **Read and write**), `too_large` (over the size limit, 64 MiB by default), `digest_mismatch` or `failed`. A snapshot ID in place of the trace ID reads a snapshot's pinned copy, which carries no media type or name. `fetch` also reads a file a producer recorded inline as a `data:` URL or base64 part of up to 1 MiB; pass the span ID for one in a log record.

`resolve` downloads each distinct digest once and returns a new value, leaving the input unchanged. By default each part gets a `data:` URL where its field takes one and bare base64 where its convention requires it, so the messages can be sent to the provider again; ask for bytes instead to write the bytes. A part whose file cannot be read stays as it is and is listed in `failures` with its JSON pointer and reason. Text kept as its first 16 KiB is never written back: pass the record's `hue.blobs` entries as `blobs`, and such a part is listed as `prefix_only`, with the SHA-256 that `fetch` reads the whole file by. Parts inside JSON-encoded strings and Langfuse media references are left as they are. The [TypeScript](/reference/typescript#recorded-media) and [Python](/reference/python#recorded-media) references list every option.

### Download over HTTP

`GET https://app.hue.run/api/v1/traces/{traceId}/blobs/{sha256}` returns a download link for a file of the trace: one that a span or correlated log record lists under `hue.blobs`, or one that a span or log record holds inline as a `data:` URL or base64 part of up to 1 MiB. Use a **Read** or **Read and write** key; any other key gets `403`.

* `traceId` is Hue's trace ID or the 32-hex OpenTelemetry trace ID.
* `sha256` is the file's lowercase hex SHA-256, as MCP [`get_span`](/agents/mcp-tools#traces-and-spans) lists it under `files`.
* The optional `spanId` query parameter, a 16-hex OpenTelemetry span ID, reads only that span and its correlated log records, which is faster in a long trace. A file that a log record holds inline, or that a span holds in a format Hue began reading after the trace was recorded, is found only with `spanId`.

```json theme={null}
{
  "sha256": "<64 hex>",
  "size": 48213,
  "contentType": "image/png",
  "fileName": "chart.png",
  "url": "https://…",
  "method": "GET",
  "expiresAt": "2026-10-12T18:05:00.000Z"
}
```

`url` is valid for five minutes and downloads the file as `fileName`. Request it without your `Authorization` header. An inline file downloads under its recorded name, or else one made from its role and digest, such as `input-4bf92f3577b3.png`.

| Status | Meaning |
| - | - |
| `400` | A malformed `traceId`, `sha256` or `spanId`. |
| `401` | The key is missing, unknown, revoked or expired. |
| `403` | The key has no **Read** access. |
| `404` | The trace is not in the key's project; no span or log record lists or holds the file (with `spanId`, none of that span's); or an inline file is over 1 MiB. With `"state": "missing"`, a record lists the file but its bytes were never uploaded or have been erased. |
| `429` | The key failed authentication too often; retry after `Retry-After`. |
| `503` | Storage is unavailable; retry after `Retry-After`. |

## Verify uploaded media

Delivery of a span does not prove its files arrived, because bytes upload after the record. Check media separately:

1. Flush with a budget that covers the uploads, then check `uploadFallbacks` / `upload_fallbacks` is unchanged.
2. Read the span with MCP `get_span`: each file in `files` should have `state: "stored"`. `missing` means the bytes never arrived. With `include_content: true`, each file also carries a five-minute `download_url`; treat it as a credential and don't paste it into chats or reports.
3. Download one file with `client.media.fetch` or the HTTP endpoint and compare its SHA-256 and size with the bytes you sent.

## Files in cases

A case keeps its own copies of the images, documents and audio its trace recorded, so they stay with the case after the trace is deleted. Hue copies up to 32 files of up to 25 MiB each. [Create a case](/evaluations/case-from-trace#files-in-a-case) explains which files judges see.

To pin input files on a case yourself, pass up to 32 `files` to `addCase` or `addEvalSetCase` (`add_case` / `add_eval_set_case` with `files=` of `CaseFileWrite` in Python): name an artifact the project already holds, or give the file's role, name, content type and bytes or path. Each file must be one of the [supported content types](/reference/typescript#file-based-cases), named with 1 to 255 UTF-16 code units and 1 byte to 25 MiB; the client uploads it as a verified artifact before the case and reuses it when you send the same case again. A file outside that policy, or a path that cannot be read or changed after the check, fails before the case is written.

During a run, the runner downloads the case's files and checks each one's size and SHA-256. The target receives the agent-visible files (`source`, `attached_template`, `attached_reference` and `original`) as `context.files`; `org_template` and `evaluator_reference` files reach only evaluators. `rehydrateMedia(inputs, files)` / `rehydrate_media(inputs, files)` writes the case's files back into recorded messages by SHA-256, so a target can send a recorded model call again with the real files.

## Coming from Langfuse or Braintrust

| Langfuse | Braintrust | Hue |
| - | - | - |
| Traces already recorded in a Langfuse project | | [`hue import langfuse`](/sdks/cli#import-traces-from-langfuse), run with your Langfuse keys |
| Base64 data URIs in an observation's input, output or metadata | | [Automatic capture](#automatic-capture) |
| `LangfuseMedia` | `Attachment` | [`Attachment`](#attach-a-file), at the same position |
| `resolveMediaReferences` | | `client.media.resolve` |
| `GET /api/public/media/{mediaId}` | `ReadonlyAttachment.data()` | `client.media.fetch` |
| | `JSONAttachment` | Nothing: a span's own string attribute over the inline limit uploads whole |
| Media tokens (`@@@langfuseMedia:…@@@`) | `ExternalAttachment` | Left as recorded; see [remote references](#remote-references) |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.