Skip to content

Attachments & multimodal

Parts go in the first argument, alongside your text — the order of text and image is semantic to a model, and an {attachments} option throws it away. A plain string prompt is unchanged and byte-identical, so no existing call site moves.

import { attach } from "toolnexus"
const res = await client.run(
["What is broken in this screenshot?", await attach("./shot.png")],
toolkit,
)

A ContentPart is text, image, file or audio, carrying base64 data or a url plus a mimeType — exactly one of the two. The mime type comes from a fixed extension table shared with the built-in read tool; it is never sniffed from content and never resolved through the machine’s mime database, whose contents differ per box and would quietly break cross-port parity. An unknown extension with no explicit mime type is a typed error naming the extension.

You are rarely holding a path. You are holding an InputStream, a FileInfo, a Blob, an io.Reader, an open file handle — and converting it by hand is the same ergonomic tax as base64-ing by hand, paid in every calling program instead of once in the library. So each port’s edge constructors take that port’s own native sources:

await attach("./invoice.pdf") // a path
await attach(blob) // a Blob / File (mime from blob.type)
await attach(buf, { mimeType: "image/png" }) // Uint8Array / Buffer / ArrayBuffer
await attach("data:image/png;base64,iVBORw0K…") // parsed, never stored as a url
await attach("https://example.com/chart.png") // kept as a url

The rule is accept broadly, store narrowly. Whatever goes in, what lands in the part — and therefore in your transcript — is bytes and a mimeType, never a handle and never a path. A stream is read eagerly at construction, because a part holding a half-read stream would survive a replay no better than a path does; a handle you pass is read, not closed — disposing it stays yours.

ToolResult gains an optional parts alongside its still-required output, so the transcript, compaction and any text-only provider keep seeing text — a screenshot tool sets output to a description and parts to the image. MCP tools get this for free: image, audio, resource_link and blob resource blocks now map onto parts on every branch, including the structuredContent and isError short-circuits. A text-only result has no parts key at all and is byte-identical to before.

defineTool({
name: "screenshot",
description: "Capture the page",
execute: async () => ({
output: "screenshot, 1280x720 png",
parts: [await attach("./shot.png")],
}),
})

The two provider styles disagree about where a tool’s image may live, and the disagreement is load-bearing:

  • anthropic accepts image blocks inside tool_result.content, keyed to the tool_use_id. Parts ride there natively.
  • openai rejects an image in a tool message outright — “Image URLs are only allowed for messages with role ‘user’”, a hard 400, verified live rather than assumed. So the tool message carries output plus text parts only, and all non-text parts from all tool results answering one assistant turn are relocated, in tool-call order, into a single synthetic user message emitted immediately after the last tool message — each part preceded by Output of tool <name> (<tool_call_id>):.

That synthetic message is an adapter artifact. It is never written to RunResult.messages, the conversation store, or translate output, so switching provider mid-conversation leaves no OpenAI-shaped residue. Relocating on every style would have been simpler to describe and worse to use: it discards the tool_use_id association, breaks cache breakpoints, and makes the model read tool output as user input.

Nothing is dropped quietly — silence is the bug this whole capability exists to remove. But erroring on everything would be its own regression, so the rule follows provenance:

the part what happens
you attached it a typed error at assembly, before any HTTP call — you asked for something specific, and silently changing it is the betrayal
it arrived from a tool a named text placeholder ([unsupported audio part (audio/wav, 41984 bytes)]) and a warn-once; the run continues

Set onUnsupportedPart to "error" or "text" to override both uniformly — onUnsupportedPart in js/java, on_unsupported_part in python/elixir, OnUnsupportedPart in Go/C#, :on-unsupported-part in Clojure.

The guard is a positive allowlist over the encoded block, not a mapping that hopes for the best. That is not caution for its own sake: sending an unrecognised block type upstream returns HTTP 200 with the content silently discarded — the same failure this release fixes, one layer up. anthropic names audio as a refusal, because the provider defines no audio block; openai refuses a file part carrying a url, because Chat Completions has no URL form for one.

Gemini request emission is out of scope — ClientStyle is openai | anthropic, and toGemini emits tool declarations only. Attachments work on both implemented styles.

Limits, and what the token estimate is for

Section titled “Limits, and what the token estimate is for”

maxPartBytes caps a part’s decoded byte length — never the +33% base64 string — and is enforced at request assembly, over every part regardless of provenance. Assembly, not construction, is the guarantee: a part that arrived from an MCP server never passed through an edge constructor, and a limit that a tool-supplied 50 MB image can walk around is not a limit. Going over follows the same provenance rule as an unsupported part, so a remote server still cannot fail your run.

Spelling per port: maxPartBytes (js, java), max_part_bytes (python, Elixir), MaxPartBytes (Go, C#), :max-part-bytes (Clojure). Every port takes it as a client option enforced at assembly, and every port also accepts it per part at construction as a fast-fail convenience — ContentPart.image!("./shot.png", max_part_bytes: 5_000_000).

A part is charged to the compactor at max(85, floor(decodedBytes / 750)) tokens, identical in every port. Two things it is deliberately not: the length of the mimeType string (which scores a 5 MB image at ~3 tokens and makes it uncompactable), and the default ceil(chars/4) over the base64 (which scores that same image at ~1.7M tokens and makes it the only thing the compactor ever evicts). Both extremes were reached independently by different ports before the formula was pinned.

It is an estimator, not a tokenizer, and cannot be otherwise: real vision cost is not proportional to bytes at all. The same 82-byte examples/media/fixture.png measured 8 500 prompt tokens on gpt-4o-mini and 258 on gemini-2.5-flash-lite. No byte-derived formula is accurate across providers, so the contract optimises for the one property the compactor needs — every port agrees, and a part is never free.

Logs and events render a part as {type, mimeType, bytes}. data is never logged.

Next: Multi-turn memory.