> ## Documentation Index
> Fetch the complete documentation index at: https://allhandsai-claude-fervent-panini-f9fcbb.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Fork a Conversation

> Branch off an existing conversation for follow-up exploration without contaminating the original.

export const path_to_script_0 = "examples/01_standalone_sdk/48_conversation_fork.py"

> A ready-to-run example is available [here](#ready-to-run-example)!

## Overview

`Conversation.fork()` deep-copies a conversation — events, agent config, workspace metadata — into a new conversation with its own ID. The fork starts in `idle` status and retains the full event memory of the source, so calling `run()` picks up right where the original left off.

**Use cases:**

* **CI debugging** — an agent produced a wrong patch; fork to debug without losing the original run's audit trail
* **A/B testing** — fork at a given turn, change one variable, compare downstream outcomes
* **Tool-change** — fork and swap in a different agent with new tools mid-conversation

## Basic Usage

### Create a fork

```python icon="python" focus={6} wrap theme={null}
source = Conversation(agent=agent, workspace=workspace)
source.send_message("Analyse the sales report.")
source.run()

# Fork the conversation
fork = source.fork()

# The fork has the same events — agent remembers the full history
fork.send_message("Now focus on the EMEA region.")
fork.run()  # Continues from the source's state
```

### Source stays immutable

Forking deep-copies events and state. Anything you do on the fork never touches the source:

```python icon="python" wrap theme={null}
source_events_before = len(source.state.events)

fork = source.fork()
fork.send_message("Extra question")

assert len(source.state.events) == source_events_before  # unchanged
```

### Fork with a different agent

Swap the agent on fork — useful for A/B testing models or adding/removing tools:

```python icon="python" focus={4-8} wrap theme={null}
alt_llm = LLM(model="openai/gpt-4o", api_key=api_key, usage_id="alt")
alt_agent = Agent(llm=alt_llm, tools=[Tool(name=TerminalTool.name)])

fork = source.fork(
    agent=alt_agent,
    tags={"variant": "B"},
)
fork.run()  # Same history, different model
```

### Tags and metadata

Forks support arbitrary `tags` for organization:

```python icon="python" wrap theme={null}
fork = source.fork(
    tags={"purpose": "debugging", "triggered_by": "ci-pipeline"},
)

print(fork.state.tags)
# {'purpose': 'debugging', 'triggered_by': 'ci-pipeline'}
```

### Titles

`LocalConversation` does not store titles. Its `fork(title=...)` parameter is
deprecated since `1.54.0`, with removal scheduled for `1.60.0`. Passing a title is
still accepted for compatibility, but emits a `DeprecationWarning` and has no
effect. Use `fork()` without `title` for local conversations.

`RemoteConversation.fork(title=...)` continues to support titles through the
agent server:

```python icon="python" wrap theme={null}
remote_fork = remote_source.fork(
    title="Debug investigation",
    tags={"purpose": "debugging"},
)
```

The server stores the title in `StoredConversation.title` and returns it in
`ConversationInfo.title`. The title parameter never adds or overwrites a tag;
an explicitly supplied `tags["title"]` remains a user-defined tag.

### Metrics reset

By default, cost/token stats start fresh on the fork. Pass `reset_metrics=False` to preserve them:

```python icon="python" wrap theme={null}
# Cost starts at 0 on the fork (default)
fork_fresh = source.fork()

# Cost carries over from source
fork_with_history = source.fork(reset_metrics=False)
```

## API Reference

```python icon="python" wrap theme={null}
def fork(
    self,
    *,
    conversation_id: ConversationID | None = None,  # auto-generated if None
    agent: AgentBase | None = None,                  # deep-copy of source agent if None
    title: str | None = None,                        # remote forks only; deprecated locally
    tags: dict[str, str] | None = None,              # arbitrary metadata
    reset_metrics: bool = True,                      # cost/tokens start fresh
) -> Conversation:
```

| Parameter | Default | Description |
| - | - | - |
| `conversation_id` | auto-generated UUID | ID for the forked conversation |
| `agent` | deep-copy of source | Agent for the fork (swap model, tools, etc.) |
| `title` | `None` | Sets the agent-server fork title for `RemoteConversation`; deprecated and ignored by `LocalConversation` |
| `tags` | `None` | Arbitrary key-value metadata |
| `reset_metrics` | `True` | Whether cost/token stats start at zero |

**Returns:** A new `Conversation` with the same event history but independent state.

## What Gets Copied

| Component | Behavior |
| - | - |
| **Events** | Deep-copied; source is never modified |
| **Agent** | Deep-copied by default, or replaced via the `agent` kwarg |
| **Workspace** | Shared (same working directory) |
| **Agent state** | Deep-copied (custom runtime data accumulated during the conversation) |
| **Activated knowledge skills** | Copied (list of skill names activated in the source) |
| **Stats / Metrics** | Reset by default (`reset_metrics=True`); pass `False` to carry over |
| **Tags** | Fresh from kwargs; source tags are **not** inherited |
| **Execution status** | Always `idle` on the fork |
| **Conversation ID** | New UUID (or explicit via `conversation_id`) |

## Agent-Server REST Endpoint

When using the [agent-server](/sdk/guides/agent-server/overview), forks are available via REST:

```bash icon="terminal" theme={null}
POST /api/conversations/{id}/fork
```

**Request body** (all fields optional):

```json theme={null}
{
  "id": "custom-uuid-or-null",
  "title": "Debug investigation",
  "tags": {"purpose": "debugging"},
  "reset_metrics": true
}
```

**Response:** Standard `ConversationInfo` for the newly created fork.

When you call `fork()` on a `RemoteConversation`, the SDK sends this request for
you and returns a new `RemoteConversation` pointing at the server-side copy.
Remote forks always reuse the server-managed agent configuration, so
`RemoteConversation.fork(agent=...)` is intentionally unsupported.

The request's `title` is stored separately from `tags` and is included in the
response's `title` field.

## Agent-Server Example

<Note>
  This example is available on GitHub: [examples/02\_remote\_agent\_server/11\_conversation\_fork.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/11_conversation_fork.py)
</Note>

```python icon="python" expandable examples/02_remote_agent_server/11_conversation_fork.py theme={null}
"""Fork a conversation through the agent server REST API.

Demonstrates ``RemoteConversation.fork()`` which delegates to the server's
``POST /api/conversations/{id}/fork`` endpoint.  The fork deep-copies
events and state on the server side, then returns a new
``RemoteConversation`` pointing at the copy.

Scenarios covered:
  1. Run a source conversation on the server
  2. Fork it — verify independent event histories
  3. Fork with a title and custom tags
"""

import os
import subprocess
import sys
import tempfile
import threading
import time

from pydantic import SecretStr

from openhands.sdk import LLM, Agent, Conversation, RemoteConversation, Tool, Workspace
from openhands.tools.terminal import TerminalTool


# -----------------------------------------------------------------
# Managed server helper (reused from example 01)
# -----------------------------------------------------------------
def _stream_output(stream, prefix, target_stream):
    try:
        for line in iter(stream.readline, ""):
            if line:
                target_stream.write(f"[{prefix}] {line}")
                target_stream.flush()
    except Exception as e:
        print(f"Error streaming {prefix}: {e}", file=sys.stderr)
    finally:
        stream.close()


class ManagedAPIServer:
    """Context manager that starts and stops a local agent-server."""

    def __init__(self, port: int = 8000, host: str = "127.0.0.1"):
        self.port = port
        self.host = host
        self.process: subprocess.Popen[str] | None = None
        self.base_url = f"http://{host}:{port}"

    def __enter__(self):
        print(f"Starting agent-server on {self.base_url} ...")
        self.process = subprocess.Popen(
            [
                "python",
                "-m",
                "openhands.agent_server",
                "--port",
                str(self.port),
                "--host",
                self.host,
            ],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            env={"LOG_JSON": "true", **os.environ},
        )
        assert self.process.stdout is not None
        assert self.process.stderr is not None
        threading.Thread(
            target=_stream_output,
            args=(self.process.stdout, "SERVER", sys.stdout),
            daemon=True,
        ).start()
        threading.Thread(
            target=_stream_output,
            args=(self.process.stderr, "SERVER", sys.stderr),
            daemon=True,
        ).start()

        import httpx

        for _ in range(30):
            try:
                if httpx.get(f"{self.base_url}/health", timeout=1.0).status_code == 200:
                    print(f"Agent-server ready at {self.base_url}")
                    return self
            except Exception:
                pass
            assert self.process.poll() is None, "Server exited unexpectedly"
            time.sleep(1)
        raise RuntimeError("Server failed to start in 30 s")

    def __exit__(self, *args):
        if self.process:
            self.process.terminate()
            try:
                self.process.wait(timeout=5)
            except subprocess.TimeoutExpired:
                self.process.kill()
                self.process.wait()
            time.sleep(0.5)
            print("Agent-server stopped.")


# -----------------------------------------------------------------
# Config
# -----------------------------------------------------------------
api_key = os.getenv("LLM_API_KEY")
assert api_key, "LLM_API_KEY must be set"

llm = LLM(
    model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
    api_key=SecretStr(api_key),
    base_url=os.getenv("LLM_BASE_URL"),
)
agent = Agent(llm=llm, tools=[Tool(name=TerminalTool.name)])

# -----------------------------------------------------------------
# Run
# -----------------------------------------------------------------
with ManagedAPIServer(port=8002) as server:
    workspace_dir = tempfile.mkdtemp(prefix="fork_demo_")
    workspace = Workspace(host=server.base_url, working_dir=workspace_dir)

    # =============================================================
    # 1. Source conversation
    # =============================================================
    source = Conversation(agent=agent, workspace=workspace)
    assert isinstance(source, RemoteConversation)

    source.send_message("Run `echo hello-from-source` in the terminal.")
    source.run()

    print("=" * 64)
    print("  RemoteConversation.fork() — Agent-Server Example")
    print("=" * 64)
    print(f"\nSource conversation ID : {source.id}")
    source_event_count = len(source.state.events)
    print(f"Source events count    : {source_event_count}")

    # =============================================================
    # 2. Fork and continue independently
    # =============================================================
    fork = source.fork(title="Follow-up fork")
    assert isinstance(fork, RemoteConversation)

    print("\n--- Fork created ---")
    print(f"Fork ID                : {fork.id}")
    fork_event_count = len(fork.state.events)
    print(f"Fork events (copied)   : {fork_event_count}")

    assert fork.id != source.id
    # The fork copies all persisted events from the server-side EventLog.
    # The source's client-side list may additionally contain transient
    # WebSocket-only events (e.g. full-state snapshots) that are never
    # persisted, so we only assert the fork has a non-trivial number of
    # events rather than exact parity.
    assert fork_event_count > 0

    fork.send_message("Now run `echo hello-from-fork` in the terminal.")
    fork.run()

    print("\n--- After running fork ---")
    print(f"Source events          : {len(source.state.events)}")
    print(f"Fork events (grew)     : {len(fork.state.events)}")
    assert len(fork.state.events) > fork_event_count

    # =============================================================
    # 3. Fork with tags
    # =============================================================
    fork_tagged = source.fork(
        title="Tagged experiment",
        tags={"purpose": "a/b-test"},
    )
    assert isinstance(fork_tagged, RemoteConversation)

    print("\n--- Fork with tags ---")
    print(f"Fork ID     : {fork_tagged.id}")

    fork_tagged.send_message(
        "What command did you run earlier? Just tell me, no tools."
    )
    fork_tagged.run()

    print(f"Fork events : {len(fork_tagged.state.events)}")

    # =============================================================
    # Summary
    # =============================================================
    print(f"\n{'=' * 64}")
    print("All done — RemoteConversation.fork() works end-to-end.")
    print("=" * 64)

    # Cleanup
    fork.close()
    fork_tagged.close()
    source.close()

cost = llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```

## Ready-to-run Example

<Note>
  This example is available on GitHub: [examples/01\_standalone\_sdk/48\_conversation\_fork.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/48_conversation_fork.py)
</Note>

```python icon="python" expandable examples/01_standalone_sdk/48_conversation_fork.py theme={null}
"""Branch a conversation. The conversation is a tree (every event has a
``parent_id``; the HEAD is ``leaf_event_id``), exposing three primitives:

  - ``fork()`` — deep-copy the whole conversation into a new one; ``run()``
    resumes with full event memory.
  - ``fork(from_event_id=...)`` — fork only the branch up to a chosen event;
    the fork's HEAD is that event.
  - ``navigate_to(event_id)`` — move HEAD within one conversation; appending
    after navigating creates a sibling branch, abandoned events stay on disk.

Use cases: debugging a wrong CI patch off the original's audit trail,
A/B-testing prompts, fork-on-tool-change, edit-a-past-turn-and-re-run.
"""

import os

from openhands.sdk import LLM, Agent, Conversation, Tool
from openhands.sdk.event import MessageEvent
from openhands.tools.terminal import TerminalTool


# -----------------------------------------------------------------
# Setup
# -----------------------------------------------------------------
llm = LLM(
    model=os.getenv("LLM_MODEL", "gpt-5.5"),
    api_key=os.getenv("LLM_API_KEY"),
    base_url=os.getenv("LLM_BASE_URL", None),
)

agent = Agent(llm=llm, tools=[Tool(name=TerminalTool.name)])
cwd = os.getcwd()

# =================================================================
# 1. Run the source conversation
# =================================================================
source = Conversation(agent=agent, workspace=cwd)
source.send_message("Run `echo hello-from-source` in the terminal.")
source.run()

print("=" * 64)
print("  Conversation.fork() — SDK Example")
print("=" * 64)
print(f"\nSource conversation ID : {source.id}")
print(f"Source events count    : {len(source.state.events)}")

# =================================================================
# 2. Fork and continue independently
# =================================================================
fork = source.fork()
source_event_count = len(source.state.events)

print("\n--- Fork created ---")
print(f"Fork ID                : {fork.id}")
print(f"Fork events (copied)   : {len(fork.state.events)}")

assert fork.id != source.id
assert len(fork.state.events) == source_event_count

fork.send_message("Now run `echo hello-from-fork` in the terminal.")
fork.run()

# Source is untouched
assert len(source.state.events) == source_event_count
print("\n--- After running fork ---")
print(f"Source events (unchanged): {source_event_count}")
print(f"Fork events (grew)       : {len(fork.state.events)}")

# =================================================================
# 3. Fork with a different agent (tool-change / A/B testing)
# =================================================================
alt_llm = LLM(
    model=os.getenv("LLM_MODEL", "gpt-5.5"),
    api_key=os.getenv("LLM_API_KEY"),
    base_url=os.getenv("LLM_BASE_URL", None),
    usage_id="alt",
)
alt_agent = Agent(llm=alt_llm, tools=[Tool(name=TerminalTool.name)])

fork_alt = source.fork(
    agent=alt_agent,
    tags={"purpose": "a/b-test"},
)

print("\n--- Fork with alternate agent ---")
print(f"Fork ID     : {fork_alt.id}")
print(f"Fork tags   : {dict(fork_alt.state.tags)}")

fork_alt.send_message("What command did you run earlier? Just tell me, no tools.")
fork_alt.run()

print(f"Fork events : {len(fork_alt.state.events)}")

# =================================================================
# 4. Branch-slice fork: fork from a chosen past event
# =================================================================
# Fork from the first user message: copies only path_to_root(event) and sets
# the fork's HEAD there.
cut_event_id = next(
    e.id
    for e in source.state.events
    if isinstance(e, MessageEvent) and e.source == "user"
)

fork_slice = source.fork(from_event_id=cut_event_id)

print("\n--- Branch-slice fork (from_event_id) ---")
print(f"Cut event id          : {cut_event_id}")
print(f"Source events (full)  : {len(source.state.events)}")
print(f"Fork events (sliced)  : {len(fork_slice.state.events)}")
print(f"Fork HEAD             : {fork_slice.state.leaf_event_id}")

# The slice contains only the branch up to the cut point.
assert len(fork_slice.state.events) <= len(source.state.events)
assert fork_slice.state.leaf_event_id == cut_event_id

fork_slice.send_message("Run `echo sliced-branch` in the terminal.")
fork_slice.run()
print(f"Fork events after run : {len(fork_slice.state.events)}")
assert any("sliced-branch" in str(e) for e in fork_slice.state.events)

# =================================================================
# 5. In-conversation navigation: move HEAD and create a sibling branch
# =================================================================
# Move HEAD back to the cut point and send a new message: a sibling branch.
# The abandoned branch stays on disk but leaves the agent's active context.
events_before_nav = len(source.state.events)

source.navigate_to(cut_event_id)
print("\n--- navigate_to (in-conversation branching) ---")
print(f"HEAD moved to         : {source.state.leaf_event_id}")
print(f"Active branch length  : {len(source.state.view.events)} events in context")
print(f"Events still on disk  : {events_before_nav}")

source.send_message("Actually, instead run `echo sibling-branch`.")
source.run()
print(f"Events on disk now     : {len(source.state.events)} (old branch retained)")

# =================================================================
# Summary
# =================================================================
print(f"\n{'=' * 64}")
print("All done — fork() works end-to-end.")
print("=" * 64)

# Report cost
cost = llm.metrics.accumulated_cost + alt_llm.metrics.accumulated_cost
print(f"EXAMPLE_COST: {cost}")
```

You can run the example code as-is.

<Note>
  The model name should follow the [LiteLLM convention](https://models.litellm.ai/): `provider/model_name` (e.g., `anthropic/claude-sonnet-4-5-20250929`, `openai/gpt-4o`).
  The `LLM_API_KEY` should be the API key for your chosen provider.
</Note>

<CodeGroup>
  <CodeBlock language="bash" filename="Bring-your-own provider key" icon="terminal" wrap>
    {`export LLM_API_KEY="your-api-key"\nexport LLM_MODEL="anthropic/claude-sonnet-4-5-20250929"  # or openai/gpt-4o, etc.\ncd software-agent-sdk\nuv run python ${path_to_script_0}`}
  </CodeBlock>

  <CodeBlock language="bash" filename="OpenHands Cloud" icon="terminal" wrap>
    {`# https://app.all-hands.dev/settings/api-keys\nexport LLM_API_KEY="your-openhands-api-key"\nexport LLM_MODEL="openhands/claude-sonnet-4-5-20250929"\ncd software-agent-sdk\nuv run python ${path_to_script_0}`}
  </CodeBlock>
</CodeGroup>

<Tip>
  **ChatGPT Plus/Pro subscribers**: You can use `LLM.subscription_login()` to authenticate with your ChatGPT account and access Codex models without consuming API credits. See the [LLM Subscriptions guide](/sdk/guides/llm-subscriptions) for details.
</Tip>

## Next Steps

* **[Persistence](/sdk/guides/convo-persistence)** — Save and restore conversation state
* **[Pause and Resume](/sdk/guides/convo-pause-and-resume)** — Control execution flow
* **[Agent Server](/sdk/guides/agent-server/overview)** — Deploy agents with the REST API


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