The short version: when a tool call fails, you still have to tell the model — and the two major APIs disagree on how. Anthropic gives you a dedicated flag: you return a tool_result block with is_error: true. OpenAI gives you nothing structural: there is no error field for your function outputs, so you put the failure text in the ordinary output (Responses API) or content (Chat Completions) string and let the model read it as the result. If you copy an Anthropic-shaped is_error into an OpenAI call, it's silently ignored; if you leave either provider's tool call unanswered, you get a hard error. Here are the exact shapes.

Anthropic: the failure is a flag#

In the Claude Messages API, a tool result is a content block in the next user message. To report success you send content; to report failure you add is_error: true:

{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
      "content": "ConnectionError: the weather service returned HTTP 500",
      "is_error": true
    }
  ]
}

Three rules from Anthropic's docs are worth tattooing on the loop:

One thing you don't flag: Anthropic's server-side tools (like web search) handle their own errors internally — is_error is only for the tools you execute.

OpenAI: the failure is just the output#

OpenAI has no is_error. In the Responses API, a tool result is a function_call_output item, and the error goes straight into output:

{
  "type": "function_call_output",
  "call_id": "call_abc123",
  "output": "ERROR: invalid 'location' argument — expected a city name"
}

In Chat Completions, it's a message with role: "tool" and the matching tool_call_id, error text in content:

messages.append({
    "role": "tool",
    "tool_call_id": tool_call.id,
    "content": "ERROR: invalid 'location' argument — expected a city name",
})

Because there's no structural signal, make the string legible to the model — an ERROR: prefix or a tiny {"error": "..."} object both read cleanly. And the same closing rule applies: leave a call_id unanswered and OpenAI throws "No tool output found for function call."

The portable mental model: Anthropic separates the fact of failure (is_error) from the description (content). OpenAI folds both into one string. If you write an agent that targets both, your tool layer needs to emit two shapes from one internal error object.

Here's that adapter — one internal result, two wire formats:

def anthropic_result(tool_use_id: str, text: str, is_error: bool) -> dict:
    block = {"type": "tool_result", "tool_use_id": tool_use_id, "content": text}
    if is_error:
        block["is_error"] = True          # Anthropic's dedicated flag
    return block                          # goes in a user message's content[]

def openai_result(call_id: str, text: str, is_error: bool) -> dict:
    out = f"ERROR: {text}" if is_error else text   # no flag — bake it into output
    return {"type": "function_call_output", "call_id": call_id, "output": out}

Which failures even reach the model#

Not every error should become a tool_result. Split them by where they belong:

RETRYABLE = {408, 409, 429, 500, 502, 503, 504, 529}   # your code retries these
TERMINAL  = {400, 401, 402, 403, 404, 413, 422}        # stop; don't retry

Two rules that survive any provider#

Whatever you target, two things never change. Close every open tool call — an unanswered call is a hard error on both platforms, so a failed tool still gets a result, it just gets an error result. And the message is a prompt: write the one sentence that tells the model what went wrong and what to try next, strip the stack trace and any secrets (a tool result is untrusted input and a prompt-injection surface), and lean on strict schema modes — Anthropic's strict tool use, OpenAI's strict function schemas — to delete the whole bad-argument class before it happens.

Get the wire format right and the model becomes a surprisingly good recovery engine. Get it wrong and it either never sees the failure — or never gets to run at all.