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

# Tool result auditing

> Find new tool results before they merge into chat history, and deduplicate audit writes with a durable idempotency key.

Use `chat.history.extractNewToolResults(message)` to find incoming tool results that aren't resolved in the current transcript. Make the resulting audit write idempotent in your database so retries can't create duplicate records.

The helper applies to [human-in-the-loop](https://trigger.dev/docs/ai-chat/patterns/human-in-the-loop.md) answers sent with `addToolOutput`. It compares the incoming message with the current history; it doesn't track whether an external write succeeded.

> [!NOTE]
>
> The `hydrateMessages` examples below apply to existing integrations. That hook is deprecated. For new conversation persistence, use [transcript storage](https://trigger.dev/docs/ai-chat/transcript-storage.md).

## The pattern

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { auditLog } from "@/lib/audit";
import { db } from "@/lib/db";
import { anthropic } from "@ai-sdk/anthropic";

export const myChat = chat.agent({
  id: "my-chat",
  hydrateMessages: async ({ chatId, incomingMessages }) => {
    for (const msg of incomingMessages) {
      for (const r of chat.history.extractNewToolResults(msg)) {
        await auditLog.record({
          chatId,
          toolCallId: r.toolCallId,
          toolName: r.toolName,
          output: r.output,
          errorText: r.errorText,
        });
      }
    }
    return await db.getMessages(chatId);
  },
  run: async ({ messages, signal, streamText }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

The hook fires per turn. `incomingMessages` is the new wire message (0-or-1-length, see [v4.5 wire format change](https://trigger.dev/docs/ai-chat/upgrade-guide.md#v45-wire-format-change)). For each new tool result on that message, write one audit row. Then return the canonical chain from your DB.

`extractNewToolResults` compares the message against the current `chat.history` chain and returns only tool parts whose `toolCallId` is **not** already resolved. For results already present in that history:

* A re-emitted message with the same resolved `toolCallId` returns `[]`.
* A new tool result on a known assistant message is returned.
* A first-time tool result returns the full set.

## Why `hydrateMessages` is the right hook

The pattern works in any pre-merge callback, but `hydrateMessages` is the canonical spot for two reasons:

1. **It fires before the runtime merges** the incoming message into the accumulator. Once merged, the tool results are already on the chain, and `extractNewToolResults` returns `[]` for them.
2. **It fires on each turn**, including turns that receive a user's answer through `addToolOutput`.

By the time `onTurnComplete` fires, the chain already contains `responseMessage`, so calling `extractNewToolResults(responseMessage)` there returns `[]`. Don't put audit logging there for the resolution path.

## Audit server-executed tools in `onTurnComplete`

If you don't use `hydrateMessages`, the runtime's snapshot+replay path handles persistence. You can still audit the agent's **own** tool executions in `onTurnComplete` by iterating the emitted parts and using an idempotent audit writer:

```ts theme={"theme":"css-variables"}
onTurnComplete: async ({ chatId, newUIMessages }) => {
  // The assistant message from this turn is in newUIMessages.
  for (const msg of newUIMessages) {
    if (msg.role !== "assistant") continue;
    for (const part of msg.parts) {
      if (
        typeof part.type === "string" &&
        part.type.startsWith("tool-") &&
        ((part as any).state === "output-available" ||
         (part as any).state === "output-error")
      ) {
        await auditLog.record({
          chatId,
          toolCallId: (part as any).toolCallId,
          toolName: (part as any).type.slice("tool-".length),
          output: (part as any).output,
          errorText: (part as any).errorText,
        });
      }
    }
  }
},
```

`newUIMessages` contains the messages this turn produced. Retries and merged assistant messages can expose a tool result again, so deduplicate these writes in your database too.

This works for tools the agent itself calls (no HITL pause). For HITL flows where the user resolves a tool with `addToolOutput`, the resolution arrives on the **next** turn's wire message, not in `newUIMessages` of the resolving turn. Use `hydrateMessages` for those in existing integrations.

## Idempotency at the storage layer

Even with `extractNewToolResults`, transient failures (e.g. an audit-log POST that times out and is retried) can produce duplicates. Make the audit-log writer idempotent on `(chatId, toolCallId)`:

```ts theme={"theme":"css-variables"}
await auditLog.upsert({
  where: { chatId_toolCallId: { chatId, toolCallId: r.toolCallId } },
  create: { /* ... */ },
  update: { /* timestamp, retry count, etc. */ },
});
```

A replay of the same tool call keeps its `toolCallId`. A new model-generated invocation can have a new ID; use an application operation ID as well when separate calls must refer to the same external action.

## What `extractNewToolResults` returns

```ts theme={"theme":"css-variables"}
type ChatNewToolResult = {
  toolCallId: string;
  toolName: string;
  output: unknown;      // The tool's return value (carries the resolved value; in output-error state see errorText)
  errorText?: string;   // Set iff the part is in output-error state
};
```

Tool parts in `input-available` state (the model called the tool but it hasn't resolved yet) aren't returned. The helper returns resolved results.

## Combining with HITL

[Human-in-the-loop](https://trigger.dev/docs/ai-chat/patterns/human-in-the-loop.md) tools pause the turn waiting for `addToolOutput` from the frontend. When the user submits, the wire message carries an updated assistant message with the tool now in `output-available` state. `extractNewToolResults` against that message returns the newly resolved tool. An idempotent audit writer keeps one row per resolution:

```ts theme={"theme":"css-variables"}
hydrateMessages: async ({ chatId, incomingMessages }) => {
  for (const msg of incomingMessages) {
    for (const r of chat.history.extractNewToolResults(msg)) {
      // Deduplicate the write by chatId and toolCallId.
      await auditLog.record({ chatId, /* ... */ });
    }
  }
  return await db.getMessages(chatId);
}
```

See the [HITL pattern's net-new-tool-result section](https://trigger.dev/docs/ai-chat/patterns/human-in-the-loop.md#acting-once-per-net-new-tool-result).

## See also

* [`chat.history`](https://trigger.dev/docs/ai-chat/backend#chat-history): full reference for `extractNewToolResults`, `getPendingToolCalls`, `getResolvedToolCalls`
* [Human-in-the-loop](https://trigger.dev/docs/ai-chat/patterns/human-in-the-loop.md): the pattern this auditing hook complements
* [`hydrateMessages`](https://trigger.dev/docs/ai-chat/lifecycle-hooks#hydratemessages): where pre-merge auditing lives
* [Persistence and replay](https://trigger.dev/docs/ai-chat/patterns/persistence-and-replay.md): how the runtime rebuilds chains, and why `extractNewToolResults` works against them


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