Claude Agent SDKclaude-agent-sdk 0.2.157 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
46 small wins to finish your pathNext lesson →

Resuming a session

Resuming is handing an earlier session's id back so the agent carries on where it stopped. The conversation comes back; the files the agent touched do not.

Last updated: 20 Oct, 2026 · Claude Agent SDK

One ClaudeSDKClient holds a conversation while your program runs. A customer who comes back tomorrow needs it from storage, which is where the real Claude Code keeps it: the sessions page puts the files under ~/.claude/projects/, one .jsonl per session, in a folder named after the working directory.

Giving the desk a shelf of sessions

shop_desk.py grows the part it has been missing: a place to put a finished turn and a way to load it back. It adds SAVED, connect, restore, newest and truncate, replaces the top of start, and adds one line to finish.

python
# Claude Code keeps one .jsonl file per session; the desk keeps one dictionary
SAVED = {}
python
    async def connect(self):
        # a refused resume has to fail where the SDK starts the session
        self.restore()
        await super().connect()
python
    def restore(self):
        o = self.options
        source = o.resume or (self.newest() if o.continue_conversation else None)
        if source is None or source not in SAVED:
            return
        marks, said = SAVED[source]
        if o.resume_session_at:
            marks, said = self.truncate(marks, said)

        # the loaded turns are the context the next question is answered against
        self.marks, self.said = list(marks), list(said)
        self.session_id = source

resume names a session. continue_conversation names none and takes the most recent one.

python
    def newest(self):
        # continue_conversation takes the most recent session and needs no id
        return list(SAVED)[-1] if SAVED else None
python
    def truncate(self, marks, said):
        # resume_session_at loads up to and including that message, and no further
        end = marks.index(self.options.resume_session_at) + 1
        drops = self.options.resume_drops_turn

        # resume_drops_turn names the one turn the cut is allowed to throw away
        if drops is not None and marks[end:end + 1] != [drops]:
            raise ProcessError("Resume rejected by --resume-drops-turn: "
                               "the dropped range is not that turn", exit_code=1)
        return marks[:end], said[:end]
python
    async def start(self, prompt):
        self.said.append(prompt)
        self.marks.append(f"uuid_{len(self.marks) + 1}")

        # replay-user-messages asks for the prompt back with the uuid it was
        # stored under, which is the only way to learn that id
        if "replay-user-messages" in self.options.extra_args:
            await self.send({"type": "user", "uuid": self.marks[-1],
                             "parent_tool_use_id": None, "session_id": self.session_id,
                             "message": {"role": "user", "content": prompt}})
python
        # the turn is on the shelf now, which is what a resume will read back
        SAVED[self.session_id] = (list(self.marks), list(self.said))

A real session id is a UUID and a real message id is one too. The desk counts instead, so the printed output is the same on every machine.

Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports these files. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep them in the same folder.
View the code here
watch.py
from claude_agent_sdk import (AssistantMessage, ResultMessage, SystemMessage,
                              TextBlock, ToolResultBlock, ToolUseBlock, UserMessage, query)

from shop_desk import ShopDesk


def describe(message):
    # the two messages with no content blocks are read from their own fields
    if isinstance(message, SystemMessage):
        return f"system     {message.subtype}"
    if isinstance(message, ResultMessage):
        return f"result     {message.subtype}: {message.result or message.errors}"

    # a type this course does not use prints its name rather than failing
    if not isinstance(message, (AssistantMessage, UserMessage)):
        return type(message).__name__
    return describe_block(message.content[0])


def describe_block(block):
    if isinstance(block, TextBlock):
        return f"assistant  {block.text}"
    if isinstance(block, ToolUseBlock):
        return f"assistant  calls {block.name} {block.input}"

    # a tool result sits on the user's side, and its error flag is worth seeing
    if isinstance(block, ToolResultBlock):
        flag = " (error)" if block.is_error else ""
        return f"user       result{flag}: {block.content}"
    return type(block).__name__


async def watch(prompt, options, model=None):
    async for message in query(prompt=prompt, options=options,
                               transport=ShopDesk(options, model)):
        print(describe(message))
shop_desk.py
import asyncio
import itertools
import json
import re

from claude_agent_sdk import ClaudeAgentOptions, ProcessError, Transport

ORDER = re.compile(r"\b[A-Z]\d+\b")
SESSIONS = itertools.count(1)

# Claude Code keeps one .jsonl file per session; the desk keeps one dictionary
SAVED = {}


class ShopModel:
    """Stands in for Claude: reads the conversation, picks the next step."""

    def __init__(self, script=None):
        self.script = list(script or [])

    def reply(self, said, results, tools):
        # a scripted reply lets a lesson force a step the stand-in would not pick
        if self.script:
            return self.script.pop(0)
        if results:
            return {"text": " ".join(results)}

        # the newest message first, then the last id named earlier in the chat
        orders = ORDER.findall(said[-1]) or ORDER.findall(" ".join(said))[-1:]
        wanted = "refund_order" if "refund" in said[-1].lower() else "lookup_order"
        tool = next((name for name in tools if name.endswith(wanted)), None)
        if orders and tool:
            return {"calls": [(tool, {"order_id": order}) for order in orders]}
        return {"text": f"I cannot help with order {orders[0]} yet." if orders
                else "Which order is this about?"}


class ShopDesk(Transport):
    """Stands in for the Claude Code binary: it runs the loop over JSON lines."""

    def __init__(self, options=None, model=None):
        self.options = options or ClaudeAgentOptions()
        self.model = model or ShopModel()

        # a counted id, not a UUID, so the printed output is the same every run
        self.session_id = f"session-{next(SESSIONS)}"
        self.inbox, self.outbox = asyncio.Queue(), asyncio.Queue()
        self.prompts, self.said, self.marks = asyncio.Queue(), [], []
        self.ids, self.setup, self.waiting = itertools.count(1), {}, {}
        self.tools, self.failed, self.ready = {}, None, False

    async def connect(self):
        # a refused resume has to fail where the SDK starts the session
        self.restore()
        self.ready = True

    def restore(self):
        o = self.options
        source = o.resume or (self.newest() if o.continue_conversation else None)
        if source is None or source not in SAVED:
            return
        marks, said = SAVED[source]
        if o.resume_session_at:
            marks, said = self.truncate(marks, said)

        # the loaded turns are the context the next question is answered against
        self.marks, self.said = list(marks), list(said)
        self.session_id = source

    def newest(self):
        # continue_conversation takes the most recent session and needs no id
        return list(SAVED)[-1] if SAVED else None

    def truncate(self, marks, said):
        # resume_session_at loads up to and including that message, and no further
        end = marks.index(self.options.resume_session_at) + 1
        drops = self.options.resume_drops_turn

        # resume_drops_turn names the one turn the cut is allowed to throw away
        if drops is not None and marks[end:end + 1] != [drops]:
            raise ProcessError("Resume rejected by --resume-drops-turn: "
                               "the dropped range is not that turn", exit_code=1)
        return marks[:end], said[:end]

    def is_ready(self):
        return self.ready

    async def write(self, data):
        # one JSON line at a time, which is what the SDK writes to a real process
        await self.inbox.put(json.loads(data))

    async def end_input(self):
        # for a real process this would close its standard input
        await self.inbox.put(None)

    async def close(self):
        self.ready = False

    async def send(self, message):
        await self.outbox.put(message)

    async def read_messages(self):
        # the listener and the worker run alongside the SDK's own read loop
        asyncio.create_task(self.listen())
        self.worker = asyncio.create_task(self.work())

        while (message := await self.outbox.get()) is not None:
            if isinstance(message, Exception):
                raise message
            yield message

    async def listen(self):
        while (message := await self.inbox.get()) is not None:
            # a control request is the SDK asking a question; initialize is first
            if message["type"] == "control_request":
                if message["request"]["subtype"] == "initialize":
                    self.setup = message["request"]
                await self.respond(message["request_id"])

            # a control response answers a question the desk itself asked
            elif message["type"] == "control_response":
                answer = message["response"]
                self.waiting.pop(answer["request_id"]).set_result(answer)
            elif message["type"] == "user":
                await self.prompts.put(message["message"]["content"])

        # the input has ended: finish the work in hand, then pass on any failure
        await self.prompts.put(None)
        await self.worker
        await self.send(self.failed)

    async def respond(self, request_id):
        # the stand-in says yes to every request the SDK makes of it
        await self.send({"type": "control_response", "response": {
            "subtype": "success", "request_id": request_id, "response": {}}})

    async def ask(self, request):
        # a future per request, so the answer can be awaited where it was asked
        request_id = f"desk_{next(self.ids)}"
        self.waiting[request_id] = asyncio.get_running_loop().create_future()
        await self.send({"type": "control_request", "request_id": request_id,
                         "request": request})
        return await self.waiting[request_id]

    async def work(self):
        while (prompt := await self.prompts.get()) is not None:
            await self.turn(prompt)

    async def start(self, prompt):
        self.said.append(prompt)
        self.marks.append(f"uuid_{len(self.marks) + 1}")

        # replay-user-messages asks for the prompt back with the uuid it was
        # stored under, which is the only way to learn that id
        if "replay-user-messages" in self.options.extra_args:
            await self.send({"type": "user", "uuid": self.marks[-1],
                             "parent_tool_use_id": None, "session_id": self.session_id,
                             "message": {"role": "user", "content": prompt}})
        if not self.tools:
            self.tools = await self.find_tools()
        await self.send({"type": "system", "subtype": "init", "model": "shop-desk",
                         "session_id": self.session_id, "tools": list(self.tools)})

    async def turn(self, prompt):
        # UserPromptSubmit fires before the model is given the prompt
        await self.hooks("UserPromptSubmit", {"prompt": prompt})
        await self.start(prompt)
        results, turns, limit = [], 0, self.options.max_turns
        while "calls" in (step := self.model.reply(self.said, results, self.tools)):
            turns += 1

            # max_turns is the desk's own stop, not something the model respects
            if limit and turns > limit:
                message = f"Reached maximum number of turns ({limit})"
                return await self.finish("error_max_turns", turns, message)
            results = [await self.use(name, args) for name, args in step["calls"]]

        await self.say(step["text"])

        # Stop fires once the model has answered and wants no more tools
        await self.hooks("Stop", {"stop_hook_active": False})
        await self.finish("success", turns + 1, step["text"])

    async def say(self, text):
        # with include_partial_messages on, the text goes out a word at a time
        if self.options.include_partial_messages:
            for word in text.split(" "):
                delta = {"type": "text_delta", "text": word + " "}
                await self.send({"type": "stream_event", "uuid": f"ev_{next(self.ids)}",
                                 "session_id": self.session_id, "event": {
                                     "type": "content_block_delta", "index": 0,
                                     "delta": delta}})

        # the whole message still follows, so code written earlier keeps working
        await self.send(self.message("assistant", [{"type": "text", "text": text}]))

    async def finish(self, subtype, turns, text):
        ok = subtype == "success"
        result = {"type": "result", "subtype": subtype, "is_error": not ok,
                  "duration_ms": 0, "duration_api_ms": 0, "num_turns": turns,
                  "session_id": self.session_id, "total_cost_usd": 0.0,
                  "usage": {"input_tokens": len(" ".join(self.said).split()),
                            "output_tokens": len(text.split())}}

        # cost is zero and usage counts words, because no model was called
        result.update({"result": text} if ok else {"errors": [text]})

        # the turn is on the shelf now, which is what a resume will read back
        SAVED[self.session_id] = (list(self.marks), list(self.said))
        await self.send(result)
        self.failed = ProcessError("Command failed", exit_code=1) if not ok else None

    def message(self, kind, content, parent=None):
        body = {"role": kind, "content": content}
        if kind == "assistant":
            body.update(model="shop-desk", id=f"msg_{next(self.ids)}")
        return {"type": kind, "message": body, "parent_tool_use_id": parent,
                "session_id": self.session_id}

    async def mcp(self, server, method, params):
        message = {"jsonrpc": "2.0", "id": next(self.ids), "method": method,
                   "params": params}
        answer = await self.ask({"subtype": "mcp_message", "server_name": server,
                                 "message": message})
        return answer["response"]["mcp_response"]["result"]

    async def find_tools(self):
        tools = {}
        for server in self.options.mcp_servers:
            await self.mcp(server, "initialize", {"protocolVersion": "2025-06-18",
                           "capabilities": {}, "clientInfo": {"name": "desk", "version": "1"}})

            for tool in (await self.mcp(server, "tools/list", {}))["tools"]:
                name = f"mcp__{server}__{tool['name']}"

                # a tool left out here never reaches the model at all
                if name not in self.options.disallowed_tools:
                    tools[name] = (server, tool["name"])
        return tools

    async def use(self, name, args):
        call = f"toolu_{next(self.ids)}"
        block = {"type": "tool_use", "id": call, "name": name, "input": args}
        await self.send(self.message("assistant", [block]))

        allowed, args = await self.permit(name, args, call)
        text, failed = (await self.run(name, args, call)) if allowed else (args, True)

        # a denied or raising call gets its own event, so a failure is never silent
        if failed:
            await self.hooks("PostToolUseFailure", {"tool_name": name, "error": text,
                                                    "tool_input": args, "tool_use_id": call})

        result = {"type": "tool_result", "tool_use_id": call, "content": text,
                  "is_error": failed}
        await self.send(self.message("user", [result]))
        return text

    async def run(self, name, args, call):
        # the full name carries the server it came from, so the call goes back there
        server, tool = self.tools[name]
        result = await self.mcp(server, "tools/call", {"name": tool, "arguments": args})

        # the hooks see the answer before the model does, with the same call id
        text = result["content"][0]["text"]
        for output in await self.hooks("PostToolUse", {"tool_name": name, "tool_input": args,
                                                       "tool_response": text, "tool_use_id": call}):
            # updatedToolOutput replaces what the model is about to read
            decision = output.get("hookSpecificOutput", {})
            text = decision.get("updatedToolOutput", text)
        return text, result.get("isError", False)

    def hook_input(self, event, data):
        # the fields a hook input carries whatever the event is, plus the
        # event's own fields on top
        return {"hook_event_name": event, "session_id": self.session_id,
                "cwd": ".", "transcript_path": "", **data}

    async def hooks(self, event, data):
        outputs = []
        for group in (self.setup.get("hooks") or {}).get(event, []):
            # a matcher of None means the group runs for every tool
            # an event with no tool name tests the matcher against an empty string
            if group["matcher"] and not re.fullmatch(group["matcher"],
                                                     data.get("tool_name", "")):
                continue

            # the SDK sent ids, not functions, so the call goes back by id
            for callback in group["hookCallbackIds"]:
                await self.lifecycle(event, "hook_started")
                answer = await self.ask({"subtype": "hook_callback",
                                         "callback_id": callback,
                                         "tool_use_id": data.get("tool_use_id"),
                                         "input": self.hook_input(event, data)})
                outputs.append(answer["response"])
                await self.lifecycle(event, "hook_response")
        return outputs

    async def lifecycle(self, event, phase):
        # include_hook_events asks for these two frames around every callback
        if self.options.include_hook_events:
            await self.send({"type": "system", "subtype": phase,
                             "hook_event": event, "session_id": self.session_id,
                             "uuid": f"ev_{next(self.ids)}"})

    async def pre_hooks(self, name, args, call):
        verdict = None
        for output in await self.hooks("PreToolUse", {"tool_name": name,
                                                      "tool_input": args, "tool_use_id": call}):
            decision = output.get("hookSpecificOutput", {})

            # one deny ends it, and the other hooks' opinions no longer matter
            if decision.get("permissionDecision") == "deny":
                return "deny", decision.get("permissionDecisionReason", "Blocked by a hook")
            verdict = decision.get("permissionDecision", verdict)
            args = decision.get("updatedInput", args)
        return verdict, args

    async def permit(self, name, args, call):
        # hooks are the first step, so nothing below here can skip them
        verdict, args = await self.pre_hooks(name, args, call)
        if verdict in ("allow", "deny"):
            return verdict == "allow", args

        o = self.options
        if o.permission_mode == "bypassPermissions" or name in o.allowed_tools:
            return True, args

        # nothing approved the call and there is nobody left to ask
        if o.can_use_tool is None or o.permission_mode == "dontAsk":
            return False, f"Permission to use {name} was not granted."
        return await self.ask_permission(name, args, call)

    async def ask_permission(self, name, args, call):
        # the SDK runs your callback and writes its answer back on this channel
        decision = (await self.ask({"subtype": "can_use_tool", "tool_name": name,
                                    "input": args, "tool_use_id": call,
                                    "permission_suggestions": None,
                                    "blocked_path": None}))["response"]

        # an allow may hand back different arguments to run with
        if decision["behavior"] == "allow":
            return True, decision["updatedInput"]
        return False, decision["message"]
shop_tools.py
from claude_agent_sdk import create_sdk_mcp_server, tool

ORDERS = {"A17": "Order A17 shipped on 3 March."}


# the description is what Claude reads to decide this is the right tool
@tool("lookup_order", "Look up an order's shipping status by its id, such as A17.",
      {"order_id": str})
async def lookup_order(args):
    order = args["order_id"]

    # an unknown id is answered in words, not raised, so Claude can pass it on
    status = ORDERS.get(order, f"No order {order} was found.")
    return {"content": [{"type": "text", "text": status}]}

REFUNDED = []


@tool("refund_order", "Refund an order by its id. The money goes back to the customer.",
      {"order_id": str})
async def refund_order(args):
    REFUNDED.append(args["order_id"])
    text = f"Order {args['order_id']} has been refunded."
    return {"content": [{"type": "text", "text": text}]}

shop = create_sdk_mcp_server("shop", tools=[lookup_order, refund_order])

Capturing the id and asking a question that needs it

Two small helpers keep the runs below short: the options every call shares, and a function that runs one question on a desk of its own.

python
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, UserMessage, query
from shop_desk import ShopDesk
from shop_tools import shop

BASE = dict(mcp_servers={"shop": shop}, allowed_tools=["mcp__shop__lookup_order"],
            extra_args={"replay-user-messages": None})
MARKS = []
python
async def ask(question, **extra):
    """Run one question on its own desk and report the session id and answer."""
    options = ClaudeAgentOptions(**BASE, **extra)
    async for message in query(prompt=question, options=options,
                               transport=ShopDesk(options)):
        # replay-user-messages is what puts a uuid on these
        if isinstance(message, UserMessage) and message.uuid:
            MARKS.append(message.uuid)

        # the id and the answer both ride on the result
        if isinstance(message, ResultMessage):
            return message.session_id, message.result

Now the test that matters. The follow-up leaves the order id out, which means it can only be answered from the earlier turn.

Example
first, answer = await ask("Where is my order A17?")
print("first    ", answer)

# the same follow-up twice: once on that session, once on a new one
_, resumed = await ask("When did it ship?", resume=first)
fresh_id, fresh = await ask("When did it ship?")
print("resumed  ", resumed)
print("fresh    ", fresh)
  • The resumed run answered about A17 although the question never named it. The earlier turn came back with the session.
  • The fresh run asked which order is meant. Same question, same code, no session behind it. That is what a resume is worth.
  • The resumed run kept the first session's id. A resume adds to a session rather than making one.

Continuing without an id

continue_conversation takes the most recently saved session in the working directory, which suits a program that runs one conversation at a time.

Example
# no id passed: the desk takes the session it saved last, which was the fresh one
latest, carried = await ask("And my order B22?", continue_conversation=True)
print(latest == fresh_id, carried)

It picked the fresh session, not the first one, because that is the one saved last. On a machine running two conversations at once this is the wrong tool: keep the ids and pass resume.

Cutting a turn off the end with resume_session_at

resume_session_at loads the conversation up to and including one message and no further, so the turns after it are gone from the copy that is resumed.

Example
first, _ = await ask("Where is my order A17?")
await ask("And my order B22?", resume=first)

_, whole = await ask("When did it ship?", resume=first)
_, cut = await ask("When did it ship?", resume=first,
                   resume_session_at=MARKS[0], resume_drops_turn=MARKS[1])
print("whole session:", whole)
print("cut at the first turn:", cut)

The whole session answers about B22, the order it heard about last. The cut one answers about A17, because the B22 turn was never loaded. resume_drops_turn is the guard: it names the prompt whose turn the cut is meant to throw away.

What the guard refuses

Name the wrong turn and the resume is refused rather than quietly losing something you had not seen.

Example
await ask("When did it ship?", resume=first,
          resume_session_at=MARKS[0], resume_drops_turn="uuid_9")

The SDK's own description of resume_drops_turn says the CLI validates at load time that everything after the cut belongs to the named turn, refuses otherwise, and that a refusal arrives as an error whose message contains Resume rejected by --resume-drops-turn:. Match on that text, and treat it as final: drop the cut point and resume the whole session rather than asking again.

The flags these four options become

Each option is one flag on the command the SDK builds for Claude Code, which is the quickest way to see that they are four separate ideas.

python
import shlex
from dataclasses import replace

from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport


def flags(options):
    """The command line the SDK would start Claude Code with."""
    # a fixed cli_path keeps the printed command the same on every machine
    options = replace(options, cli_path="claude")
    command = SubprocessCLITransport(prompt="", options=options)._build_command()

    # command[0] is the binary itself, which is not the interesting part
    return shlex.join(command[1:])
Example
from claude_agent_sdk import ClaudeAgentOptions
from flags import flags

print(flags(ClaudeAgentOptions(resume="5b3f2c1a")))
print(flags(ClaudeAgentOptions(continue_conversation=True)))
print(flags(ClaudeAgentOptions(resume="5b3f2c1a", resume_session_at="7c2d",
                               resume_drops_turn="9e4f")))

resume passes its value after an equals sign, which the SDK's own source says keeps an id starting with a dash from being read as another flag. The real id must be a UUID; the desk's counted ids would be refused by Claude Code itself.

resume compared with continue_conversation

resume=continue_conversation=True
Which sessionthe one you namethe most recent in the directory
You trackthe idnothing
Two conversations at oncefinethey overwrite each other
Session not foundthe id is passed on as it isa fresh session
Flag built--resume=--continue

When a session has to come back

  • A support thread. One session id stored beside the ticket, so tomorrow's reply has yesterday's reading behind it.
  • Recovering from a limit. A run that ended on error_max_turns resumes with a higher limit instead of starting the work again.
  • A restart. Your process died; the id was written down, so the conversation did not die with it.
Watch out. Resuming restores the conversation, not the working directory. The sessions page is explicit that sessions persist the conversation and not the filesystem, so a resumed agent believes it already edited files that may have changed or vanished underneath it. Putting file changes back is a separate feature, and the file checkpointing lesson shows it.
Try it yourself
  • Pass resume="never-saved" and read what comes back. Does the desk fail, or start fresh?
  • Ask the follow-up as "When did A17 ship?" on a fresh session. Does it still need the history?
  • Set resume_session_at to the second uuid instead of the first and run the cut again.

Little by little, you're building something great.