# Tool result auditing

> Source: https://trigger.dev/docs/ai-chat/patterns/tool-result-auditing

The AI Agents and Prompts surface ships as part of the **v4.5 release candidate**. Install with `@trigger.dev/sdk@rc` (or pin `4.5.0-rc.0` or later) to use these features — they aren’t yet on the latest stable, and APIs may still change before the 4.5.0 GA. See [supported AI SDK versions](https://trigger.dev/docs/ai-chat/reference#compatibility)
 and the [AI chat changelog](https://trigger.dev/docs/ai-chat/changelog)
 for details.

When a chat agent uses [tools](https://trigger.dev/docs/ai-chat/tools)
 (especially [human-in-the-loop](https://trigger.dev/docs/ai-chat/patterns/human-in-the-loop)
 tools that wait on `addToolOutput` from the frontend), you often need to fire side effects exactly once per resolved tool call:

*   **Audit logs** — record every tool result for compliance.
*   **Billing** — charge per tool invocation.
*   **Notifications** — alert downstream systems when a specific tool resolves.
*   **Search-index updates** — reflect tool outputs into a derived store.

The naive approach — “log every tool part you see” — over-counts. The same assistant message gets re-shown across re-renders, replays, and retries. You want a function of the form **“is this tool result one I haven’t already logged?”** That’s exactly what [`chat.history.extractNewToolResults`](https://trigger.dev/docs/ai-chat/backend#chat-history)
 returns.

[​](https://trigger.dev/docs/ai-chat/patterns/tool-result-auditing#the-pattern)

The pattern
----------------------------------------------------------------------------------------------

    import { chat } from "@trigger.dev/sdk/ai";
    import { auditLog } from "@/lib/audit";
    
    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 }) => {
        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#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. That’s what makes the call exactly-once:

*   A re-emitted message (same id, same toolCallId) returns `[]` — no duplicate log.
*   A genuinely new tool result on a known assistant message returns just the new ones.
*   A first-time tool result returns the full set.

[​](https://trigger.dev/docs/ai-chat/patterns/tool-result-auditing#why-hydratemessages-is-the-right-hook)

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 always fires per turn** — including HITL turns where the user resolved a tool with `addToolOutput`, which is the highest-volume audit event in most apps.

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.

[​](https://trigger.dev/docs/ai-chat/patterns/tool-result-auditing#without-hydratemessages-%E2%80%94-onturncomplete-for-self-emitted-tool-calls)

Without `hydrateMessages` — `onTurnComplete` for self-emitted tool calls
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

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` — but compare against the prior message rather than the just-emitted one:

    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` is just the messages this turn produced — no prior-chain noise. Each tool part shows up exactly once. 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.

[​](https://trigger.dev/docs/ai-chat/patterns/tool-result-auditing#idempotency-at-the-storage-layer)

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 `toolCallId`:

    await auditLog.upsert({
      where: { toolCallId: r.toolCallId },
      create: { /* ... */ },
      update: { /* timestamp, retry count, etc. */ },
    });
    

`toolCallId` is unique per tool invocation (assigned by the AI SDK when the model emits the tool call) and stable across retries — perfect for an idempotency key.

[​](https://trigger.dev/docs/ai-chat/patterns/tool-result-auditing#what-extractnewtoolresults-returns)

What `extractNewToolResults` returns
----------------------------------------------------------------------------------------------------------------------------------------------

    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) are not returned — only **resolved** results count.

[​](https://trigger.dev/docs/ai-chat/patterns/tool-result-auditing#combining-with-hitl)

Combining with HITL
--------------------------------------------------------------------------------------------------------------

[Human-in-the-loop](https://trigger.dev/docs/ai-chat/patterns/human-in-the-loop)
 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 just-resolved tool — exactly one audit row per user resolution:

    hydrateMessages: async ({ chatId, incomingMessages }) => {
      for (const msg of incomingMessages) {
        for (const r of chat.history.extractNewToolResults(msg)) {
          // Fires once per ask_user / approval / similar resolution
          await auditLog.record({ chatId, /* ... */ });
        }
      }
      return await db.getMessages(chatId);
    }
    

This is the original motivator for the helper — see the [HITL pattern’s net-new-tool-result section](https://trigger.dev/docs/ai-chat/patterns/human-in-the-loop#acting-once-per-net-new-tool-result)
.

[​](https://trigger.dev/docs/ai-chat/patterns/tool-result-auditing#see-also)

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)
     — 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)
     — how the runtime rebuilds chains, and why `extractNewToolResults` works against them

Was this page helpful?

YesNo

[Previous](https://trigger.dev/docs/ai-chat/patterns/human-in-the-loop)
[Large payloadsWhy a single chunk on the chat stream is capped at ~1 MiB, what error you'll see, and how to work around it with ID references.\
\
Next](https://trigger.dev/docs/ai-chat/patterns/large-payloads)

⌘I

Assistant

Responses are generated using AI and may contain mistakes.
