# How Tool Result Persistence Works Across Workflow Steps in Open Agents

> Discover how Open Agents ensures tool result persistence across workflow steps by upserting assistant messages to PostgreSQL, providing durability through VM restarts and device switches.

- Repository: [Vercel Labs/open-agents](https://github.com/vercel-labs/open-agents)
- Tags: internals
- Published: 2026-04-16

---

**Tool results are persisted by eagerly upserting assistant messages containing terminal UI parts to PostgreSQL before the workflow completes, ensuring durability across VM restarts and device switches.**

Open Agents by Vercel Labs implements chat sessions as durable workflows that must survive VM restarts, user device switches, and long-running operations. To prevent tool execution results from being lost between steps, the platform implements eager persistence of assistant messages containing terminal tool states to an external PostgreSQL database.

## The High-Level Persistence Flow

The persistence mechanism operates in four distinct stages:

- **Client-side completion**: When a tool finishes executing (e.g., `ask_user_question`), the assistant message contains UI parts with terminal states like `output-available`, `output-error`, or `approval-responded`.

- **API route invocation**: The `/api/chat` handler in [`apps/web/app/api/chat/route.ts`](https://github.com/vercel-labs/open-agents/blob/main/apps/web/app/api/chat/route.ts) receives the POST request and fires `persistAssistantMessagesWithToolResults` in fire-and-forget mode.

- **Processing and deduplication**: The function validates terminal states, runs `dedupeMessageReasoning` to remove duplicate reasoning chunks from replayed workflow steps, and prepares the message for storage.

- **Database upsert**: The final message is written to the PostgreSQL `sessions` table via `upsertChatMessageScoped`, handling potential ID conflicts from concurrent requests.

## Detecting Terminal Tool States

The persistence logic only activates when the assistant message contains tool UI parts in terminal states. In [`apps/web/app/api/chat/_lib/persist-tool-results.ts`](https://github.com/vercel-labs/open-agents/blob/main/apps/web/app/api/chat/_lib/persist-tool-results.ts), the detection logic inspects each part:

```typescript
const hasToolResults = latestMessage.parts.some(
  (part) =>
    isToolUIPart(part) &&
    (part.state === "output-available" ||
     part.state === "output-error" ||
     part.state === "approval-responded"),
);

```

Only messages satisfying this condition proceed to the upsert stage. Non-terminal states (like `input-required` or `running`) are ignored, as the workflow expects these to transition to completion before persistence.

## Deduplicating Reasoning Parts

Durable workflows replay steps when resuming from a pause, which can cause reasoning chunks to be streamed multiple times. Before persisting, `dedupeMessageReasoning` in [`apps/web/lib/chat/dedupe-message-reasoning.ts`](https://github.com/vercel-labs/open-agents/blob/main/apps/web/lib/chat/dedupe-message-reasoning.ts) removes these duplicates:

```typescript
const dedupedMessage = dedupeMessageReasoning(latestMessage);

```

This function examines the `parts` array and eliminates duplicate reasoning entries generated during workflow replays, preventing database bloat and ensuring idempotent upserts. The deduplication logic typically compares content hashes or unique identifiers within reasoning parts to identify duplicates.

## Upserting to PostgreSQL

The final persistence step uses `upsertChatMessageScoped` from [`apps/web/lib/db/sessions.ts`](https://github.com/vercel-labs/open-agents/blob/main/apps/web/lib/db/sessions.ts) to write the prepared message to the database:

```typescript
const result = await upsertChatMessageScoped({
  id: dedupedMessage.id,
  chatId,
  role: "assistant",
  parts: dedupedMessage,
});

if (result.status === "conflict") {
  console.warn(`Skipped assistant tool‑result upsert due to ID conflict: ${latest.id}`);
}

```

The function handles **ID-scope conflicts** gracefully—if another concurrent request already inserted the same message ID, the conflict is logged as a warning rather than throwing an error. This ensures that retries or concurrent executions don't corrupt the session state.

## Fire-and-Forget Invocation

Persistence operations run asynchronously to avoid blocking the workflow execution. In [`apps/web/app/api/chat/route.ts`](https://github.com/vercel-labs/open-agents/blob/main/apps/web/app/api/chat/route.ts), the API handler invokes the persistence function without awaiting the result:

```typescript
void persistAssistantMessagesWithToolResults(chatId, messages);

```

This **fire-and-forget** pattern ensures that the durable workflow can proceed immediately while the database write happens in the background. Even if the persistence promise rejects, the workflow continues—errors are logged but don't halt execution, adhering to the principle that persistence is a "best-effort" durability enhancement rather than a blocking requirement.

## Durability Across Workflow Pauses

The persistence mechanism operates independently of the sandbox snapshot system. While [`packages/sandbox/interface.ts`](https://github.com/vercel-labs/open-agents/blob/main/packages/sandbox/interface.ts) defines `snapshot()` for VM filesystem state, tool results persist through the **PostgreSQL `sessions` table** external to the VM.

This architectural separation means:

- **VM restarts**: When a workflow resumes on a new VM, it reads the persisted assistant message from the database rather than relying on in-memory state.
- **Device switches**: Users can start a tool execution on one device and view results on another, as the session state lives in the shared database.
- **Long-running operations**: Tools that take minutes or hours to complete remain durable, with results available to subsequent workflow steps regardless of VM lifecycle events.

## Summary

- Tool results are encoded in assistant message UI parts with terminal states (`output-available`, `output-error`, or `approval-responded`).
- The `/api/chat` route triggers eager persistence via `persistAssistantMessagesWithToolResults` in [`apps/web/app/api/chat/_lib/persist-tool-results.ts`](https://github.com/vercel-labs/open-agents/blob/main/apps/web/app/api/chat/_lib/persist-tool-results.ts).
- Reasoning parts are deduplicated via `dedupeMessageReasoning` before storage to handle workflow replays.
- Messages are upserted to PostgreSQL via `upsertChatMessageScoped`, with conflict handling for concurrent requests.
- Fire-and-forget invocation ensures workflow continuity while durability is handled asynchronously.
- External database storage ensures results survive VM restarts, device switches, and long pauses.

## Frequently Asked Questions

### What happens if two requests try to persist the same tool result simultaneously?

The `upsertChatMessageScoped` function detects ID-scope conflicts and returns a status of `"conflict"` rather than throwing an error. The logging system records a warning, and the workflow continues unaffected, ensuring that concurrent persistence attempts don't corrupt the session state.

### How does this differ from the sandbox snapshot mechanism?

Tool result persistence stores assistant messages in PostgreSQL via the `sessions` table, independent of the VM filesystem. The sandbox snapshot system defined in [`packages/sandbox/interface.ts`](https://github.com/vercel-labs/open-agents/blob/main/packages/sandbox/interface.ts) captures VM state for execution continuity, but tool results rely on the external database to survive across VM restarts and device switches.

### Why is reasoning deduplication necessary before persisting?

Durable workflows replay steps when resuming from a pause, which can cause streaming reasoning chunks to be re-applied and duplicated. The `dedupeMessageReasoning` function removes these duplicates before storage to prevent database bloat and ensure that replayed workflow steps produce idempotent results.

### What states trigger tool result persistence?

Persistence only occurs when the assistant message contains UI parts in terminal states: `output-available`, `output-error`, or `approval-responded`. Non-terminal states like `input-required` or `running` are not persisted, as the workflow expects these to transition to completion before storage.