Introduction: From One-Shot to Conversations

Welcome to the next step of your mastery journey! You learned how to enable built-in tools that transform your agent from a text responder into an active assistant capable of searching the web, reading and writing files, and executing commands. However, all your interactions so far have used the query() function, which creates a fresh session for each request. This stateless approach works perfectly for one-off tasks, but it has a significant limitation — the agent has no memory of previous interactions.

In this lesson, you'll learn how to move beyond single queries to multi-turn conversations by using ClaudeSDKClient. This session-based client maintains context across multiple interactions, allowing you to have back-and-forth conversations in which the agent remembers what you discussed earlier. You'll learn how to create and manage a client, control its lifecycle with connection patterns, conduct multi-turn conversations, and observe how context preservation enables sophisticated workflows that build on previous work.

Why ClaudeSDKClient: Stateful vs Stateless

The query() function you've been using creates a new session for every call, which means each interaction is completely independent. When you call query() with a prompt, the SDK starts a fresh agent session, processes your request, returns the response, and then discards all context. This stateless behavior is simple and efficient for one-off tasks, but it prevents the agent from building on previous work or answering follow-up questions.

ClaudeSDKClient takes a different approach by maintaining a persistent session across multiple interactions. When you create a client and send queries through it, the agent remembers everything from previous messages in that session. This stateful behavior enables natural conversations in which you can ask follow-up questions, provide clarifications, or build complex workflows that span multiple exchanges.

Featurequery()ClaudeSDKClient
Session behaviorCreates a new session for each call; no memory of previous interactions.Maintains a persistent session across multiple turns—full context is preserved.
Memory and contextNo memory between calls—each query starts fresh with no knowledge of prior exchanges.Remembers everything from previous messages in the same session, enabling natural follow-ups.
Use caseOne-off tasks: single prompt → single response → done. Best for independent requests.Multi-turn conversations: follow-ups, clarifications, building on previous work. Best for chatbots.
Lifecycle managementAutomatic—session is created, used, and destroyed with each call. No manual control needed.Manual—requires explicit connect() and disconnect() or async context manager (async with).
Advanced featuresSupports built-in tools (like WebSearch, Bash, Read, Write), but not custom tools or hooks.Full support: custom tools, hooks, interrupts, permission callbacks, and fine-grained session control.
Multi-turn chat continuityNot suitable—each call resets context, making follow-up questions impossible.Designed precisely for continuity—agent can reference and build upon earlier conversation turns.
Resource efficiencyEfficient for single tasks—no session overhead between calls.Requires active session—more overhead but enables sophisticated workflows that span multiple exchanges.

The choice between query() and ClaudeSDKClient depends on your use case. Use query() when you need quick, independent responses — like generating a single piece of text, answering a standalone question, or performing a one-time task, and you only need built-in tool support. Use ClaudeSDKClient when you need conversations with context — like building a chatbot, conducting multi-step workflows in which later steps depend on earlier results, or creating interactive applications in which users have back-and-forth exchanges with the agent, and when you need custom tools or advanced session control.

Creating a ClaudeSDKClient

Creating a ClaudeSDKClient uses the same ClaudeAgentOptions configuration you learned in previous lessons. You configure the model, system prompt, tool access, permissions, and working directory exactly as before, then pass those options to the client constructor.

from pathlib import Path
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions

# Configure the agent with ClaudeAgentOptions
options = ClaudeAgentOptions(
    model="haiku",
    system_prompt="You are a helpful assistant.",
    max_turns=10,
    allowed_tools=["WebSearch", "WebFetch", "Bash", "Read", "Write", "Edit"],
    permission_mode="acceptEdits",
    cwd=Path(__file__).parent
)

# Create a client for stateful conversation
client = ClaudeSDKClient(options=options)

The code creates a ClaudeAgentOptions object with the same configuration pattern you've used before. The only new element is the final line, which creates a ClaudeSDKClient by passing the options to its constructor. At this point, the client exists but hasn't connected to the agent runtime yet — you need to explicitly manage the session lifecycle to start the conversation.

Connecting and Disconnecting Manually

Once you've created a ClaudeSDKClient, you need to establish a connection before you can send queries. The most straightforward approach is to explicitly call connect() to start the session and disconnect() to end it.

import anyio
from pathlib import Path
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions

async def main():
    # Configure the agent with ClaudeAgentOptions...
    
    # Create the client (not connected yet)
    client = ClaudeSDKClient(options=options)
    
    # Manually connect to start the session
    await client.connect()
    
    try:
        # Your conversation code goes here...
    finally:
        # Always disconnect to cleanup resources
        await client.disconnect()

if __name__ == "__main__":
    anyio.run(main)

The code creates a client, then calls await client.connect() to establish the session. The try/finally block ensures disconnect() is called even if errors occur, preventing resource leaks. This manual approach gives you explicit control over session lifetime, which is useful for long-running services or frameworks that don't work well with context managers.

Automatic Session Management with Context Managers

For most use cases, Python's async context manager provides a cleaner alternative that automatically handles connection and disconnection. This pattern is recommended because it ensures proper cleanup even if errors occur.

import anyio
from pathlib import Path
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions

async def main():
    # Configure the agent with ClaudeAgentOptions...
    
    # Use ClaudeSDKClient with automatic connection management
    async with ClaudeSDKClient(options=options) as client:
        # Your conversation code goes here...
        # Client automatically connects when entering this block
        # and disconnects when exiting

if __name__ == "__main__":
    anyio.run(main)

The async with statement automatically calls connect() when entering the block and disconnect() when exiting — whether the exit is normal or due to an exception. This eliminates the need for try/finally blocks and ensures you never accidentally leave sessions open. Inside the context manager block, you can send multiple queries, and the agent will maintain context across all of them.

Understanding query() and receive_response()

With the client connected, let's understand how to send queries and handle responses using the client's send-receive pattern. Unlike the standalone query() function that returned an async iterator directly, ClaudeSDKClient separates sending and receiving into two distinct operations.

from claude_agent_sdk import ClaudeSDKClient, AssistantMessage, TextBlock, ToolUseBlock

async with ClaudeSDKClient(options=options) as client:
    # Send a query to the agent
    await client.query(
        "Search the web for what's the difference between using query() and "
        "ClaudeSDKClient in the Claude Agent SDK. Give me a brief explanation."
    )
    
    # Receive and display the response
    async for message in client.receive_response():
        if isinstance(message, AssistantMessage):
            for block in message.content:
                if isinstance(block, TextBlock):
                    print(f"\n💬 Claude Response:")
                    print(block.text)
                elif isinstance(block, ToolUseBlock):
                    print(f"\n🔧 [Tool: {block.name}]")

The code demonstrates the fundamental send-receive pattern of ClaudeSDKClient. First, await client.query() sends a message to the agent. Then, client.receive_response() retrieves the agent's reply by returning an async iterator that yields messages in real-time as the agent works. This streaming behavior means messages arrive progressively — you see tool uses as they happen, text responses as they're generated, and results as they're produced — rather than waiting for the entire response to complete before seeing anything.

The receive_response() method works exactly like the async iterator returned by the standalone query() function you used in previous lessons — it yields the same message types (AssistantMessage, ToolResultBlock, etc.) and follows the same iteration pattern. The key difference is that with ClaudeSDKClient, you explicitly call receive_response() to start receiving messages, whereas the standalone query() function returned the iterator directly. This separation of sending (query()) and receiving (receive_response()) gives you more control over conversation flow and makes it easier to implement patterns like concurrent message handling, interrupts, or custom streaming logic.

Building a Response Display Helper

Since we'll need to receive and display responses multiple times throughout our conversation, let's create a reusable helper function that encapsulates this pattern.

async def display_response(client: ClaudeSDKClient):
    """Helper function to display agent messages with formatting"""
    async for message in client.receive_response():
        if isinstance(message, AssistantMessage):
            for block in message.content:
                if isinstance(block, TextBlock):
                    print(f"\n💬 Claude Response:")
                    print(block.text)
                elif isinstance(block, ToolUseBlock):
                    print(f"\n🔧 [Tool: {block.name}]")

The helper function takes the receive-and-display logic we just wrote and packages it into a reusable function. Now instead of writing the full async for loop and message handling every time we want to see the agent's response, we can simply call await display_response(client). This makes our conversation code cleaner and more maintainable. You can extend this helper to handle ToolResultBlock objects or other message types as needed. Now let's use this helper to conduct an actual multi-turn conversation.

Conducting a Multi-Turn Conversation

With the helper function in place, we can now conduct a complete multi-turn conversation that demonstrates context preservation. Let's send a first query and observe the response.

async with ClaudeSDKClient(options=options) as client:
    
    # First query: Search for information    
    await client.query(
        "Search the web for what's the difference between using query() and "
        "ClaudeSDKClient in the Claude Agent SDK. Give me a brief explanation."
    )
    await display_response(client)

The code sends the first query by calling await client.query() with a prompt asking the agent to search for information about the difference between query() and ClaudeSDKClient. After sending the query, it calls await display_response(client) to retrieve and display the agent's response using the helper function we just created. This separation of sending and receiving gives you more control over the conversation flow and makes it easier to implement complex interaction patterns. Let's see what the agent produces in response to this first query.

Observing the First Response

When the agent processes the first query, it searches the web for information and provides a structured explanation of the differences between the two approaches.

💬 Claude Response:
I'll search the web for information about the difference between `query()` and `ClaudeSDKClient` in the Claude Agent SDK.

🔧 [Tool: WebSearch]

💬 Claude Response:
## Brief Explanation: `query()` vs `ClaudeSDKClient`

**`query()`** - A simple, lightweight API for one-shot text generation:
- Stateless: each call is independent
- No tool support
- Best for simple, single-request interactions
- Lightweight and straightforward

**`ClaudeSDKClient`** - A fully-featured agentic API for complex interactions:
- **Stateful**: maintains conversation sessions across multiple exchanges
- **Tool support**: enables custom tools and hooks defined as Python functions
- **Multi-turn conversations**: can continue conversations with context persistence
- **Manual control**: provides more control over the agent's behavior
- Best for complex, interactive workflows requiring tool integration

**In short**: Use `query()` for simple one-off requests, and use `ClaudeSDKClient` when you need to build agents with multi-turn conversations, custom tools, or session persistence.

The agent explains its plan, uses the WebSearch tool to find information, and then provides a structured explanation of the differences. This response becomes part of the session's context, which means the agent will remember it when processing the next query. Now let's send a second query that builds on this information to demonstrate context preservation.

Sending a Follow-Up Query

The real power of ClaudeSDKClient becomes apparent when you send a second query that references information from the first interaction. Because the client maintains a persistent session, the agent can understand contextual references like "the difference you just found."

    # Second query: Edit the file (context is maintained from first query)
    await client.query(
        "Now edit the file ./summaries/claude-agent-sdk.md and add a new section "
        "at the end explaining the difference you just found."
    )
    await display_response(client)

The code sends a second query that asks the agent to edit a file and add information about "the difference you just found." Notice how this query uses the phrase "you just found" — this only makes sense if the agent remembers the first query and its results. Because both queries go through the same client session, the agent has access to the full conversation history and can understand what "the difference you just found" refers to. If you had used two separate query() calls instead of a client session, the second query would have failed because the agent wouldn't have known what information to include. Let's examine the agent's response to see how it uses the remembered context.

Observing Context Preservation

When the agent processes the second query, it demonstrates a clear understanding of the previous conversation by using the information it found earlier to complete the file editing task.

💬 Claude Response:
I'll first read the file to see its current content, then add a new section at the end.

🔧 [Tool: Read]

💬 Claude Response:
Now I'll add a new section at the end of the file explaining the difference between `query()` and `ClaudeSDKClient`:

🔧 [Tool: Edit]

💬 Claude Response:
Perfect! I've successfully added a new section to the file `./summaries/claude-agent-sdk.md` at the end. The new section includes:

1. **query() subsection** - explaining it's stateless, lightweight, has no tool support, and is best for simple single-request interactions
2. **ClaudeSDKClient subsection** - covering its stateful nature, full-featured capabilities, tool support, multi-turn conversations, and manual control
3. **When to Use Each subsection** - providing guidance on choosing between the two

The new content is clearly formatted with bullet points for easy reading and aligns with the existing document structure

The agent reads the existing file, then uses the Edit tool to add a new section based on the information it found in the first query. Notice how the agent's explanation in the final response references the same concepts it discovered during the web search — stateless vs. stateful, tool support, multi-turn conversations, and when to use each approach. This demonstrates that the agent successfully maintained context across both queries and used information from the first interaction to complete the second task. Beyond basic conversations, the client also supports more advanced control mechanisms like interrupts.

Interrupting Agents When Needed

Sometimes you need to stop an agent mid-execution — perhaps it's taking too long, going off track, or you realize you gave it the wrong instructions. The ClaudeSDKClient supports interrupts, which allow you to cancel an ongoing agent run.

To demonstrate interrupts in action, we'll use anyio.create_task_group() to run two operations concurrently — one that displays the agent's response and another that waits a few seconds before interrupting. A task group is an async pattern that lets you run multiple async functions simultaneously and ensures they all complete (or get cancelled together) before moving on.

async with ClaudeSDKClient(options=options) as client:
    # Start a query that we'll interrupt
    await client.query(
        "Search the web for what's the difference between using query() and "
        "ClaudeSDKClient in the Claude Agent SDK. Give me a brief explanation."
    )
    
    # Simulate user deciding to cancel after 3 seconds
    async def cancel_after_delay():
        await anyio.sleep(3)
        print("\n⚠️ Interrupting agent...")
        await client.interrupt()
    
    # Run both tasks concurrently
    async with anyio.create_task_group() as tg:
        tg.start_soon(display_response, client)
        tg.start_soon(cancel_after_delay)
    
    # After interrupt, continue with a simpler request
    print("\n" + "=" * 60)
    print("New Message: Simplified request after interrupt")
    print("=" * 60)
    
    await client.query(
        "Instead, just tell me in one sentence what ClaudeSDKClient is used for."
    )
    await display_response(client)

When you run this code, you'll see the agent begin its work, get interrupted mid-execution, and then respond to a new query:

💬 Claude Response:
I'll search for information about the differences between query() and ClaudeSDKClient in the Claude Agent SDK.

🔧 [Tool: WebSearch]

⚠️ Interrupting agent...

💬 Claude Response:
Based on my knowledge, **ClaudeSDKClient is the main client class used to interact with the Claude API within the Claude Agent SDK**, allowing you to make API calls and manage agent operations programmatically.

The example demonstrates a typical interrupt scenario with the following flow:

  1. Initial Query: The client sends a query asking the agent to search for and explain the difference between query() and ClaudeSDKClient.
  2. Concurrent Tasks: Using anyio.create_task_group(), two tasks run concurrently — one displaying the agent's response and another that waits 3 seconds before calling client.interrupt().
  3. Interrupt Triggered: When the interrupt occurs, the agent stops processing the current request gracefully and returns a ResultMessage with subtype='error_during_execution'.
  4. Session Continues: As long as the session remains alive within the async context manager, you can continue the conversation by calling client.query() again with a new request.
  5. Follow-Up Query: The example sends a simpler follow-up query asking for just a one-sentence explanation instead of a full web search, demonstrating that the session continues normally after the interrupt.

This capability is particularly useful in interactive applications where users might want to cancel a request and try something different, or in automated workflows where you need to enforce timeouts and retry with adjusted parameters. The interrupt mechanism works because the client maintains a persistent session — with stateless query() calls, there's no session to interrupt since each call is independent and short-lived.

Summary & Conversational Workflows Exercises

You've now learned how to move beyond single queries to multi-turn conversations using ClaudeSDKClient. By creating a session-based client with the same ClaudeAgentOptions configuration you already know, managing its lifecycle with async context managers, and conducting conversations through the send-receive pattern of client.query() and receive_response(), you can build agents that maintain context across multiple interactions. The example demonstrated how context preservation enables natural follow-up queries in which the agent remembers previous work and builds on it to complete complex tasks, opening the door to sophisticated conversational workflows that would be impossible with stateless queries.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal