Droid Python SDK

Use the Factory Droid SDK for Python to build custom agents, workflows, and product integrations.

The Droid SDK lets you run the same agent harness that powers Factory's CLI, desktop application, and web platform from your own code. It manages conversation context, tool execution, permissions, streaming, model selection, and multi-step execution.

Your application decides when Droid runs, which tools and models it can use, and what happens to the result.

Overview

The Python SDK runs a local droid subprocess and provides one-shot runs, persistent sessions, streaming, and session discovery.

Choose an API

GoalAPI
Run one prompt and return its resultawait run(...)
Keep context across promptsSession(...)
Continue a saved sessionSession.resume(...)
Find saved sessionsawait list_sessions(...)

Use Session when prompts need shared history or session operations such as interruption, compaction, or rewind. There is no session.run(); every session turn goes through stream().

What you can build

Invoke Droid when a pull request opens, a support ticket is escalated, an incident is created, or scheduled repository maintenance is due. The agent does not need a chat interface.

Install and authenticate

Install the droid-sdk package and import it as droid_sdk:

Bash
pip install droid-sdk

The package provides an async API for running Droid locally.

Requirements:

  • Python 3.10 or later
  • droid on PATH
  • an authenticated Droid CLI session or a Factory API key

The SDK uses the Droid CLI's local authentication by default. It reads FACTORY_API_KEY when present; pass api_key= only when the key comes from application configuration. The SDK never places keys in command arguments, logs, or exception messages.

Quick start

Run one prompt

Python
import asyncio
 
from droid_sdk import run
 
 
async def main() -> None:
    result = await run("Summarize this repository.")
    if result.success:
        print(result.text)
    else:
        print(result.subtype)
 
 
asyncio.run(main())

run() starts a session, runs one turn, and closes everything it created. The saved session remains resumable. See Result types for every terminal outcome.

Continue a conversation

Python
import asyncio
from pathlib import Path
 
from droid_sdk import Session
 
 
async def run_turn(session: Session, prompt: str) -> str:
    async with session.stream(prompt) as stream:
        async for _ in stream:
            pass
 
    result = stream.result
    if not result.success:
        raise RuntimeError(result.subtype)
    return result.text
 
 
async def main() -> None:
    async with Session(cwd=Path.cwd()) as session:
        print(await run_turn(session, "What does this project do?"))
        print(await run_turn(session, "What should I test first?"))
 
 
asyncio.run(main())

The second turn includes context from the first. Exiting the session closes the subprocess and session-owned resources. See Default stream types for every yielded value.

Core concepts

Session

A Session owns conversation history, settings, a working directory, and one Droid connection. It accepts one active turn at a time.

Turn

A turn begins with session.stream(prompt) and ends when the stream yields a RunResult. Create separate sessions for parallel work.

Result

A turn's outcome is a value, not an exception: RunSuccess, RunInterrupted, or RunFailure. Interrupted turns, execution failures, and structured-output failures do not raise. Failures in the machinery around a turn (setup, connection, process, protocol, timeout, and cancellation) raise exceptions.

Ownership and cleanup

ObjectOwner
Resources created by run()run()
Session used with async withThe context manager
Manually opened SessionThe caller
In-process MCP serversThe session

Typing

The package includes py.typed, and the public API type-checks under strict Pyright and mypy. Public unions narrow with isinstance(). High-level value models are immutable data classes.

CallStatic return type
run(..., output=None)RunResult[None]
run(..., output=Model)RunResult[Model]
run(..., output=JsonSchema(...))RunResult[JsonObject]
session.stream(...)RunStream[T, StreamMessage[T]]
Partial session.stream(...)RunStream[T, StreamEvent[T]]
stream.resultRunResult[T]

Sessions

Use a session when turns share history.

Create and use a session

Session is lazy. It creates the subprocess and session on entry:

Python
async with Session(
    cwd=Path.cwd(),
    model="auto",
) as session:
    async with session.stream("Review the project.") as stream:
        async for message in stream:
            handle(message)

model="auto" selects the Factory Router.

Configure behavior and attach handlers with SessionConfig and InteractionHandlers:

Python
from droid_sdk import (
    Autonomy,
    InteractionHandlers,
    PermissionRequest,
    PermissionResponse,
    SessionConfig,
    ToolConfirmationOutcome,
)
 
 
def approve(request: PermissionRequest) -> PermissionResponse:
    return request.respond(ToolConfirmationOutcome.PROCEED_ONCE)
 
 
config = SessionConfig(
    autonomy=Autonomy.LOW,
    disabled_tools={"Execute"},  # any iterable of tool IDs works here
    disable_builtin_skills=True,
)
 
async with Session(
    config=config,
    interactions=InteractionHandlers(on_permission=approve),
) as session:
    ...

Handlers are plain callables that inspect the request and choose an offered outcome; see Permissions and user input for the full contract.

SessionConfig also accepts mode-specific models, MCP servers, tags, source attribution, machine_id, automatic permission rejection, and native-tool overrides. It does not expose a system-prompt API.

Resume a saved session

Python
async with Session.resume(
    session_id,
    interactions=InteractionHandlers(on_question=answer),
    disabled_tools={"Execute"},
) as session:
    async with session.stream("Continue from the previous prompt.") as stream:
        async for _ in stream:
            pass

Get a session ID from session.id, result.session_id, or list_sessions().

Resume restores conversation history, working directory, title, and settings; runtime concerns such as handlers and MCP servers must be attached again (see the table below). It does not accept a new working directory or model.

Read session state

Python
print(session.id)
print(session.cwd)
print(session.settings.model)
print(session.settings.mode)

These properties are read-only. settings is an immutable snapshot, replaced when Droid reports a settings change.

Update session settings

Python
await session.update_settings(
    model="model-id",
    reasoning_effort=ReasoningEffort.HIGH,
    autonomy=Autonomy.MEDIUM,
    disabled_tools={"Execute"},
)

Only supplied fields change. Set nullable Spec-model fields to None to clear them.

update_settings() accepts:

FieldType
modelstr | None
reasoning_effortReasoningEffort | None
modeMode | None
autonomyAutonomy | None
spec_modelstr | None
spec_reasoning_effortReasoningEffort | None
tagsSequence[SessionTag] | None
compaction_token_limitint | None
compaction_threshold_check_enabledbool | None
additional_toolsIterable[str] | None
enabled_toolsIterable[str] | None
disabled_toolsIterable[str] | None
restrict_toolsIterable[str] | None

It returns UpdateSettingsResult. The result currently has no fields.

Rename a session

Python
await session.rename("Authentication review")

rename(title: str) returns None.

List saved sessions

Python
from droid_sdk import list_sessions
 
for saved in await list_sessions(limit=10):
    print(saved.id, saved.title, saved.modified_at)

Pass all_workspaces=True to list sessions across working directories.

list_sessions() reads local files without starting Droid. Results are newest first. Timestamps are timezone-aware.

What persists

RestoredAttach again
Conversation historyInteraction handlers
Working directorySession-scoped MCP servers
TitleObservability sinks
Session settings

Close sessions safely

Python
session = Session()
await session.open()
 
try:
    async with session.stream("Review the project.") as stream:
        async for _ in stream:
            pass
finally:
    await session.close()

open() and close() are idempotent, but a closed session cannot be reopened. Calling an active method before open() raises SessionNotOpenError.

Concurrent open() calls share one startup attempt. If one waiter is cancelled, startup continues for the others. Cancelling the final waiter cancels startup and completes resource cleanup before the session becomes retryable. close() racing startup waits for startup cleanup and leaves the session closed.

Subscribe to raw notifications

Python
unsubscribe = session.on_notification(handle_notification, type="custom_type")
try:
    ...
finally:
    unsubscribe()

High-level streams ignore unknown notifications. on_notification() exposes them without creating a second client.

Streaming and results

Use top-level run() when only the result matters. Session turns always use stream().

session.stream() accepts:

ArgumentType
promptstr
imagesSequence[Image]
filesSequence[Document]
outputtype[BaseModel] | JsonSchema | None
timeoutfloat | None
include_partial_messagesbool

Complete messages

Python
from droid_sdk import AssistantMessage
 
async with session.stream("Run the tests.") as stream:
    async for message in stream:
        if isinstance(message, AssistantMessage):
            print(message.text)

Default stream types

Python
StreamMessage[T] = (
    UserMessage
    | AssistantMessage
    | ToolCall
    | ToolResult
    | HookExecution
    | ErrorEvent
    | RunResult[T]
)

session.stream(prompt) yields:

TypeFields
UserMessageConversation-message fields
AssistantMessageConversation-message fields
ToolCallname, tool_use_id, input
ToolResulttool_use_id, tool_name, content, is_error
HookExecutionhook_id, event_name, matcher, tool_call_id, command, timeout, status, exit_code, stdout, stderr, suppress_output
ErrorEventmessage, error_type, timestamp
RunResult[T]Terminal result described below

Values appear in delivery order. RunResult[T] is always last.

Partial events

Python
from droid_sdk import Session, TextDelta
 
async with Session() as session:
    async with session.stream(
        "Explain the failing test.",
        include_partial_messages=True,
    ) as stream:
        async for event in stream:
            if isinstance(event, TextDelta):
                print(event.text, end="", flush=True)
 
    print()
    if not stream.result.success:
        print(f"Turn ended: {stream.result.subtype}")

The default stream yields complete messages. Set include_partial_messages=True to add deltas and operational events. Both modes yield the terminal result and cache it in stream.result. See Partial stream types for the complete event union.

Python
from droid_sdk import RunResult, TextDelta
 
async with session.stream(
    "Explain the failing test.",
    include_partial_messages=True,
) as stream:
    async for event in stream:
        if isinstance(event, TextDelta):
            print(event.text, end="", flush=True)
        elif isinstance(event, RunResult) and not event.success:
            print(f"\nTurn ended: {event.subtype}")

Partial stream types

Python
StreamEvent[T] = (
    StreamMessage[T]
    | TextDelta
    | TextComplete
    | ThinkingDelta
    | ThinkingComplete
    | ToolCallDelta
    | ToolProgress
    | TokenUsageUpdate
    | WorkingStateChanged
    | PermissionResolved
    | SettingsUpdated
    | SessionTitleUpdated
    | SessionWorkingDirectoryChanged
    | McpStatusChanged
    | McpAuthRequired
    | McpAuthCompleted
)

With include_partial_messages=True, the stream yields every StreamMessage[T] plus:

TypeFields
TextDeltamessage_id, block_index, text
TextCompletemessage_id, block_index
ThinkingDeltamessage_id, block_index, text
ThinkingCompletemessage_id, block_index, duration
ToolCallDeltatool_use
ToolProgresstool_use_id, tool_name, content, update
TokenUsageUpdateToken-usage fields
WorkingStateChangedstate
PermissionResolvedrequest_id, tool_use_ids, selected_option
SettingsUpdatedsettings
SessionTitleUpdatedtitle
SessionWorkingDirectoryChangedcwd
McpStatusChangedservers, summary
McpAuthRequiredserver_name, auth_url, message, state
McpAuthCompletedserver_name, outcome, message

ToolProgress.update is a ToolProgressUpdate:

TypeFields
ToolProgressUpdatetype, tool_name, status, details, text, error, timestamp, parameters, value_snippet, terminal_id, full_output, subagent_session_id

ToolProgressUpdate.type is "tool_call", "tool_result", "error", "status", or "message".

Unknown high-level events are ignored. Use session.on_notification() when the application needs raw notifications.

Message model

Python
from droid_sdk import AssistantMessage, ConversationMessage, UserMessage
 
completed: list[ConversationMessage] = []
 
async with session.stream("Explain the failing test.") as stream:
    async for event in stream:
        if isinstance(event, (UserMessage, AssistantMessage)):
            completed.append(event)
            save(event)

ConversationMessage defines the fields shared by user and assistant messages:

FieldTypeMeaning
idstrStable message ID
contenttuple[ContentBlock, ...]Ordered canonical content
textstrVisible text blocks joined in order
parent_idstr | NoneParent message, when present
created_atdatetimeTimezone-aware creation time
updated_atdatetimeTimezone-aware update time

ContentBlock is a typed union for text, thinking, tool use, tool result, redacted thinking, images, and documents. Narrow blocks with isinstance().

Python
ContentBlock = (
    TextBlock
    | ThinkingBlock
    | RedactedThinkingBlock
    | ToolUseBlock
    | ToolResultBlock
    | ImageBlock
    | DocumentBlock
)
TypeFields
TextBlockid, text
ThinkingBlockid, thinking, signature, signature_provider, duration
RedactedThinkingBlockid, data
ToolUseBlockid, name, input, thought_signature
ToolResultBlockid, tool_use_id, content, is_error
ImageBlockid, source, generated
DocumentBlockid, source

Message is StreamMessage[T] without RunResult[T]. Partial events are not messages.

TextDelta.message_id and block_index identify the assistant content block that eventually appears in AssistantMessage.content. ToolCall.tool_use_id matches the corresponding ToolUseBlock.id.

A complete message can repeat text already delivered through TextDelta. Use deltas for live rendering and complete messages for persistence. Do not concatenate both.

stream.result.messages is the ordered tuple of complete Message values emitted before the result. It excludes the result and partial events.

Handle the result

The terminal RunResult is yielded by the iterator and cached on the stream:

Python
from droid_sdk import RunResult
 
async with session.stream("Run the tests.") as stream:
    async for message in stream:
        if isinstance(message, RunResult):
            print(message.subtype)
 
result = stream.result
if result.success:
    print(result.text)

Reading stream.result before completion raises StreamIncompleteError.

Result types

RunResult[T] is a union of three terminal states:

Python
RunResult[T] = RunSuccess[T] | RunInterrupted[T] | RunFailure[T]
TypeSubtypesuccessinterrupted
RunSuccess[T]successTrueFalse
RunInterrupted[T]interruptedFalseTrue
RunFailure[T]error_during_executionFalseFalse
RunFailure[T]error_structured_outputFalseFalse
FieldTypeMeaning
subtypestrTerminal state
textstrFinal assistant text; reconstructed from deltas if no complete message arrived
messagestuple[Message, ...]Complete messages from the turn
usageUsage | NonePer-turn token and credit usage
durationtimedeltaWall-clock duration
turn_countintSDK turn count, currently 1
session_idstrSession that ran the turn
outputT | NoneLocally adapted structured output
structured_outputFrozenJsonObject | NoneImmutable raw structured output
output_validation_errorValidationError | NonePydantic failure
structured_output_errorStructuredOutputError | NoneDroid failure
errorErrorEvent | NoneTerminal execution error

All result variants retain partial output. RunFailure also exposes error and the server's structured_output_error when available. A successful turn may have no structured output.

RunResult does not define truthiness. Check success or subtype.

Token and context usage

Python
if result.usage:
    print(result.usage.input_tokens)
    print(result.usage.output_tokens)
    print(result.usage.cache_read_tokens)
    print(result.usage.cache_creation_tokens)
    print(result.usage.thinking_tokens)
    print(result.usage.factory_credits)

TokenUsageUpdate contains cumulative committed session usage. Context occupancy is separate:

Python
context = await session.context()
print(context.used, context.remaining, context.limit, context.accuracy)

ContextUsage.updated_at records when Droid measured the value.

TypeFields
Usageinput_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, thinking_tokens, factory_credits
ContextUsageused, remaining, limit, accuracy, updated_at

Errors

Python
async with session.stream("Run the tests.") as stream:
    async for _ in stream:
        pass
 
result = stream.result
if result.subtype == "interrupted":
    print("The turn was interrupted.")
elif result.subtype in ("error_during_execution", "error_structured_output"):
    print(result.error.message if result.error else result.subtype)

Setup, connection, process, protocol, and timeout failures raise DroidError subclasses. Python programming errors use normal built-in exceptions. asyncio.CancelledError is never wrapped.

Stream concurrency

A session may have one active stream. Starting another raises SessionBusyError.

Different sessions may run concurrently.

Timeout

Python
async with session.stream("Perform a long review.", timeout=60) as stream:
    async for event in stream:
        handle(event)

On expiry, the SDK interrupts the turn and raises RunTimeoutError.

Interrupt a turn

Another task may stop active work:

Python
await session.interrupt()

The active stream yields an interrupted result if its consumer remains attached. The session stays open.

Cancellation and interruption

Cancelling the task running a turn sends a best-effort interrupt, releases the subscription, then re-raises asyncio.CancelledError.

Use the stream context manager when iteration may stop early:

Python
async with session.stream("Inspect every test.") as stream:
    async for event in stream:
        if should_stop(event):
            break

Context exit interrupts unfinished work. A bare async iterator cannot guarantee immediate cleanup on break. await stream.aclose() explicitly interrupts and detaches an unfinished stream; it is idempotent.

Models

Model IDs are strings because availability depends on account and organization policy. Omit model to use the configured default, which lives in ~/.factory/settings.json under sessionDefaultSettings. An unknown model ID is rejected by the backend: the turn returns RunFailure(subtype="error_during_execution") with the rejection message in error.message.

Select a model

Python
from droid_sdk import ReasoningEffort, run
 
result = await run(
    "Review this repository.",
    model="model-id",
    reasoning_effort=ReasoningEffort.HIGH,
)

Omit reasoning_effort to use the model default.

Change the model for later turns:

Python
await session.update_settings(
    model="model-id",
    reasoning_effort=ReasoningEffort.HIGH,
)

See Update session settings for the full update_settings() contract.

Use the Factory Router

The model ID auto selects the Factory Router, which routes each prompt to the model with the best balance of quality, latency, and cost. Some product surfaces label it Auto Model; the model ID is auto everywhere.

Python
from droid_sdk import run
 
result = await run("Review this repository.", model="auto")

Pin a session to the router the same way:

Python
async with Session(model="auto") as session:
    ...

Move a live session onto the router:

Python
await session.update_settings(model="auto")

Omit reasoning_effort; the router chooses the effort along with the model and ignores a supplied value. session.settings.model reports auto; the underlying model can differ per response.

The model ID auto is unrelated to Mode.AUTO, the default interaction mode, and to Autonomy, the permission level.

See which model handled a response

Assistant messages in the wire create_message notification carry the underlying model in modelId; routerId is "auto" when the router made the choice. High-level messages omit these fields. Subscribe with on_notification():

Python
from collections.abc import Mapping
 
 
def report_routing(notification: Mapping[str, object]) -> None:
    message = notification.get("message")
    if isinstance(message, Mapping) and message.get("role") == "assistant":
        print(message.get("modelId"), message.get("routerId"))
 
 
unsubscribe = session.on_notification(report_routing, type="create_message")

Wire payloads use camelCase keys and evolve server-side; treat absent keys as normal.

Configure mode-specific models

Python
from droid_sdk import Mode, ReasoningEffort, Session, SessionConfig
 
config = SessionConfig(
    mode=Mode.SPEC,
    spec_model="model-id",
    spec_reasoning_effort=ReasoningEffort.HIGH,
)
 
async with Session(model="model-id", config=config) as session:
    async with session.stream("Draft an implementation plan.") as stream:
        async for _ in stream:
            pass

The primary model handles Auto turns. spec_model handles Spec turns. Switch modes on a live session with enter_spec() and leave_spec(); see Spec mode.

Model configuration contract

FieldTypeUsed by
modelstr | Nonerun(), Session, update_settings()
reasoning_effortReasoningEffort | NoneSame
spec_modelstr | NoneSessionConfig, update_settings()
spec_reasoning_effortReasoningEffort | NoneSame

None uses the configured default. Setting a Spec field to None through update_settings() clears it.

Inputs and outputs

Images and documents

Attach images and files to a turn with the images and files options:

Python
from droid_sdk import Document, Image, run
 
result = await run(
    "Compare these files.",
    images=[
        Image.from_path("screenshot.png"),
        Image.from_bytes(image_bytes, media_type="image/png"),
    ],
    files=[
        Document.from_path("report.pdf"),
        Document.from_text(source, name="auth.py"),
    ],
)

The constructors read local data and encode it for the turn. Supported image types are PNG, JPEG, GIF, and WebP; image URLs are unsupported. Invalid local input raises InvalidAttachmentError before the turn starts. Attachments are limited to MAX_ATTACHMENT_BYTES (5 MiB), and PDFs to MAX_PDF_ATTACHMENT_BYTES (3 MiB).

Input schemas

TypeFields
Imagesource: Base64ImageSource
Base64ImageSourcedata, media_type
Documentsource: TextDocumentSource | PdfDocumentSource
TextDocumentSourcedata, name, mime
PdfDocumentSourcedata, parsed_data, name, path
ConstructorReturns
Image.from_path(path)Image
Image.from_bytes(data, media_type=...)Image
Document.from_path(path)Document
Document.from_text(text, name=..., mime=...)Document
Document.from_bytes(data, name=...)Document

images accepts Sequence[Image]. files accepts Sequence[Document]. Wire payloads use canonical mediaType fields. The optional mime hint on text documents is forwarded with the document.

Structured output

Return a Pydantic model

Python
from typing import Literal
 
from pydantic import BaseModel
 
from droid_sdk import RunSuccess, run
 
 
class Finding(BaseModel):
    severity: Literal["low", "medium", "high"]
    message: str
 
 
class Review(BaseModel):
    summary: str
    findings: list[Finding]
 
 
result = await run(
    "Review the authentication code.",
    output=Review,
)
 
if isinstance(result, RunSuccess) and result.output is not None:
    print(result.output.summary)  # success guarantees output when requested
elif result.output_validation_error is not None:
    print(result.output_validation_error)

output accepts a BaseModel subclass, not an arbitrary class. Unsupported types raise TypeError before the turn starts. With output=Review, the return type is RunResult[Review].

When structured output arrives, the SDK validates it against the model. If output was requested, RunSuccess guarantees result.output is set. Missing or invalid output turns the result into RunFailure with subtype error_structured_output; the failure keeps the text, messages, usage, raw structured_output, and output_validation_error for inspection.

Use raw JSON Schema

Python
from droid_sdk import JsonObject, JsonSchema, RunResult, run
 
schema = JsonSchema(
    {
        "type": "object",
        "properties": {"summary": {"type": "string"}},
        "required": ["summary"],
    }
)
 
result: RunResult[JsonObject] = await run(
    "Summarize the repository.",
    output=schema,
)
 
if result.output is not None:
    print(result.output["summary"])

JsonSchema accepts an object-shaped schema. Droid reports unsupported or invalid schemas through the normal result subtype.

Output contract

output argumentReturn type
Omitted or NoneRunResult[None]
type[BaseModel]RunResult[Model]
JsonSchemaRunResult[JsonObject]

JsonSchema.schema is FrozenJsonObject. Schema mappings must contain only JSON-compatible values; the SDK validates this and freezes them recursively. Raw schema output remains JsonObject.

Python
JsonValue = bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] | None
JsonObject = dict[str, JsonValue]
FrozenJsonValue = (
    bool
    | int
    | float
    | str
    | tuple["FrozenJsonValue", ...]
    | Mapping[str, "FrozenJsonValue"]
    | None
)
FrozenJsonObject = Mapping[str, FrozenJsonValue]

Both local and Droid-side output failures produce RunFailure with subtype error_structured_output. To tell them apart, check structured_output_error.code: local failures use local_validation_failed (with the Pydantic error in output_validation_error) or local_output_missing; Droid-side failures use Droid's own codes.

Permissions and user input

Autonomy

Autonomy controls which actions require approval in Auto mode:

LevelBehavior
Autonomy.OFFAsk before every action
Autonomy.LOWAllow edits and read-only commands
Autonomy.MEDIUMAllow reversible commands
Autonomy.HIGHAllow commands without approval

Configure it through SessionConfig:

Python
config = SessionConfig(autonomy=Autonomy.LOW)

Autonomy does not select Auto or Spec mode. Mode controls that.

Permission handler

Python
from droid_sdk import (
    InteractionHandlers,
    PermissionRequest,
    PermissionResponse,
    Session,
    ToolConfirmationOutcome,
)
from droid_sdk.permissions import CreateFile
 
 
def approve(request: PermissionRequest) -> PermissionResponse:
    if request.actions and all(
        isinstance(action, CreateFile) for action in request.actions
    ):
        return request.respond(ToolConfirmationOutcome.PROCEED_ONCE)
    return request.respond(ToolConfirmationOutcome.CANCEL)
 
 
session = Session(
    interactions=InteractionHandlers(on_permission=approve),
)

Droid decides which outcomes are on offer. respond() accepts only an outcome listed in request.options, plus optional comment or edited_spec_content.

An absent handler, an invalid response, or a handler exception cancels the request. Handler failures appear as ErrorEvent values; they do not raise through the active stream.

AskUser handler

Python
from droid_sdk import QuestionRequest, QuestionResponse
 
 
def answer(request: QuestionRequest) -> QuestionResponse:
    answers = [
        question.answer(question.options[0] if question.options else "none")
        for question in request.questions
    ]
    return request.submit(answers)

Cancel the questionnaire:

Python
return request.cancel()

Answers are always strings on the wire. Without a handler, or when a handler fails or returns an invalid shape, the questionnaire is cancelled and the stream receives an ErrorEvent.

question.answer(value) answers one value. question.answer_multiple(values) joins multi-select values with ", " to produce the wire-compatible string.

Handlers run inside the active turn. Use async handlers for I/O, and do not start another turn on the same session from a handler.

Interaction contracts

Python
PermissionHandler = Callable[
    [PermissionRequest],
    PermissionResponse | Awaitable[PermissionResponse],
]
QuestionHandler = Callable[
    [QuestionRequest],
    QuestionResponse | Awaitable[QuestionResponse],
]
TypeFields
InteractionHandlerson_permission, on_question
PermissionRequestactions, options, associated_session_ids, plan
PermissionOptionlabel, value
Plantext, title
PermissionResponseselected_option, comment, edited_spec_content
QuestionRequesttool_call_id, questions
Questionindex, topic, question, options, multi_select
QuestionAnswerindex, question, answer
QuestionResponsecancelled, answers

PermissionAction is a discriminated union:

Python
PermissionAction = (
    EditAction
    | ExecuteAction
    | CreateFile
    | AskUserAction
    | ExitSpecModeAction
    | ApplyPatchAction
    | McpToolAction
    | SandboxViolationAction
    | DroidShieldViolationAction
)

Every action includes its tool_use, confirmation type, and typed details:

TypeDetail fields
EditActionfile_path, file_name, old_content, new_content
ExecuteActionfull_command, command, extracted_commands, impact_level, risk_level_reason
CreateFilefile_path, file_name, content
AskUserActionquestionnaire, questions, parse_error
ExitSpecModeActionplan, title
ApplyPatchActionfile_path, file_name, patch_content, old_content, new_content, files
ApplyPatchFilefile_path, file_name, operation, move_to, old_content, new_content
McpToolActiontool_name, server_name, actual_tool_name, impact_level
SandboxViolationActionviolating_tool_name, target, operation, violation_type, reason, violation_reason, is_org_deny
DroidShieldViolationActioncommand, reason

AskUserParseError(message: str, line: int | None = None) records malformed questionnaire details. ApplyPatchFile requires file_path, file_name, and operation ("create", "update", or "delete"); move_to, old_content, and new_content default to None.

ToolConfirmationOutcome defines:

OutcomeMeaning
PROCEED_ONCEApprove this request
PROCEED_ALWAYSPersist the offered rule
PROCEED_ALWAYS_EXACT_PATHPersist the exact file path
PROCEED_AUTO_RUNContinue with automatic approvals
PROCEED_AUTO_RUN_LOWContinue at low autonomy
PROCEED_AUTO_RUN_MEDIUMContinue at medium autonomy
PROCEED_AUTO_RUN_HIGHContinue at high autonomy
PROCEED_NEW_SESSIONContinue in a new session
PROCEED_NEW_SESSION_LOWNew session at low autonomy
PROCEED_NEW_SESSION_MEDIUMNew session at medium autonomy
PROCEED_NEW_SESSION_HIGHNew session at high autonomy
PROCEED_EDITSubmit edited plan content
PROCEED_ALWAYS_TOOLSPersist approval for MCP tools
PROCEED_ALWAYS_SERVERPersist approval for an MCP server
CANCELReject the request

Only outcomes present in PermissionRequest.options are valid.

Tool controls

Python
config = SessionConfig(
    additional_tools={"CustomTool"},
    enabled_tools={"Read"},
    disabled_tools={"Execute", "Edit"},
    restrict_tools={"Read", "Grep"},
)

Each of the four sets has a distinct role:

  • additional_tools adds IDs to the catalog.
  • enabled_tools enables otherwise available tools.
  • disabled_tools removes tools.
  • restrict_tools is a restrictive allowlist and never elevates permission.
Python
for tool in await session.list_tools(model="model-id", mode=Mode.AUTO):
    print(tool.id, tool.allowed)

update_settings() accepts the same override fields for later turns. The four override parameters accept any iterable of tool IDs, such as a set, list, or tuple. Passing a bare string raises TypeError.

list_tools() returns list[ToolInfo].

TypeFields
ToolInfoid, display_name, description, category, default_allowed, allowed
ListToolsOptionsmodel, mode, autonomy, spec_model, additional_tools, enabled_tools, disabled_tools, restrict_tools, skip_permissions_unsafe

Extensions

ExtensionUse it for
SkillsReusable instructions and supporting files
In-process MCP toolsPython functions exposed to Droid
External MCP serversTools from another process or service
HooksCommands run at Droid lifecycle points

Skills

Python
skills = await session.list_skills()
for skill in skills.skills:
    print(skill.name, skill.enabled)

skills.project_available reports whether a project skill scope is available; it is None when Droid does not report it.

Enable or disable a skill:

Python
await session.enable_skill("review", scope="project")
await session.disable_skill("legacy", scope="user")

Skills may come from project, personal, built-in, or automation settings.

Skill schemas

TypeFields
SkillsResultskills, project_available
SkillInfoname, description, location, file_path, enabled, user_invocable, version, content, resources, disabled_by
SkillResourcename, path, type
SkillMutationResultsuccess

scope is "user" or "project". SkillResource.type is "reference" or "asset". SkillInfo.location is "project", "personal", "builtin", or "automation".

In-process MCP tools

In-process servers require the mcp extra (pip install "droid-sdk[mcp]"). Use @tool to expose an annotated Python function:

Python
from droid_sdk.mcp import create_sdk_mcp_server, tool
 
 
@tool("lookup_owner", "Return the owner of a repository file.")
async def lookup_owner(path: str) -> str:
    return f"Owner for {path}: platform-team"
 
 
server = create_sdk_mcp_server(
    name="review-tools",
    tools=[lookup_owner],
    version="1.0.0",
)
config = SessionConfig(mcp_servers=[server])

The decorator derives JSON Schema from type annotations. Invalid input is returned to Droid as a tool error and is not passed to the function.

Tool functions may be synchronous or asynchronous. They may return text or a typed ToolResponse.

The SDK starts an authenticated loopback server and closes it with the session. Attach in-process servers again when resuming.

In-process MCP schemas

TypeFields
DroidToolname, description, input_schema, handler, output_schema
SdkMcpServername, version, tools
ToolResponsecontent, is_error, structured_content

tool(name, description) is a decorator; tool(name, description, function) is the equivalent direct call. A return annotation that describes an object is validated at call time, returned as structured content, and advertised through the MCP outputSchema field.

create_sdk_mcp_server(name, tools, version="1.0.0") returns SdkMcpServer. SdkMcpServer.config is the active HttpMcpServerConfig or None; await server.start() returns that config, and await server.close() stops the server. Sessions normally own these calls.

External MCP servers

External server configs do not need the mcp extra:

Python
from droid_sdk import HttpMcpServerConfig, StdioMcpServerConfig
 
config = SessionConfig(
    mcp_servers=[
        HttpMcpServerConfig(name="docs", url="https://example.com/mcp"),
        StdioMcpServerConfig(
            name="search",
            command="python",
            args=["-m", "search_server"],
        ),
    ],
)

HTTP, SSE, and stdio transports are supported. HTTP and SSE configurations support headers and OAuth settings.

Inspect connected servers and tools:

Python
servers = await session.list_mcp_servers()
tools = await session.list_mcp_tools()
 
print(servers.summary)
for server in servers.servers:
    print(server.name, server.status)

Session methods can add, remove, enable, disable, and authenticate external servers. Configuration mutations affect the user's Droid settings. Servers passed through SessionConfig are session-scoped.

Session MCP methodSignature
list_mcp_servers() -> McpServersResult
list_mcp_tools() -> list[McpToolInfo]
add_mcp_server(config) -> McpMutationResult
remove_mcp_server(name: str) -> McpMutationResult
enable_mcp_server / disable_mcp_server(name: str) -> McpMutationResult
enable_mcp_tool / disable_mcp_tool(server_name: str, tool_name: str) -> McpMutationResult
authenticate_mcp_server(name: str) -> McpMutationResult
cancel_mcp_auth / clear_mcp_auth(name: str) -> McpMutationResult
submit_mcp_auth_code(name: str, *, code: str, state: str) -> McpMutationResult
submit_mcp_auth_error(name: str, *, error: str, state: str, error_description: str | None = None) -> McpMutationResult

External MCP schemas

Python
McpServerConfig = (
    StdioMcpServerConfig | HttpMcpServerConfig | SseMcpServerConfig | SdkMcpServer
)
TypeFields
StdioMcpServerConfigname, command, args, env
HttpMcpServerConfigname, url, headers, oauth
SseMcpServerConfigname, url, headers, oauth
HttpHeadername, value
McpOAuthOptionsscopes, resource, authorization_server_issuer, client_metadata_url, client_id, client_secret, callback_port, token_endpoint_auth_method
McpServersResultservers, summary
McpServerStatusInfoname, status, source, is_managed, error, tool_count, server_type, has_auth_tokens, requires_auth, pending_auth_url, pending_auth_message, pending_auth_state
McpStatusSummarytotal, connected, connecting, failed, disabled, config_error
McpConfigErrorpath, message
McpToolInfoserver_name, name, description, is_enabled, is_read_only, input_schema
McpToolInputSchematype, properties, required
McpMutationResultsuccess

oauth accepts McpOAuthOptions or False.

Remove, enable, and disable mutate user-scoped MCP configuration only. These methods do not accept a project-scope argument.

Hooks

There is no Python API for defining hooks; configure them in .factory/hooks.json. Hook execution appears as HookExecution values in the run stream. HookExecution.status is "started", "completed", or "error".

Session lifecycle

Fork, compact, and rewind create successor sessions. The successor is an opened Session that takes over the existing Droid connection; fork() returns it directly, while compact() and rewind() return it on their outcome objects.

Fork

Fork copies the conversation into a new session and continues there.

Python
fork = await session.fork(
    title="Alternative approach",
    tags=[SessionTag(name="experiment")],
)
 
async with fork:
    async with fork.stream("Try the other strategy.") as stream:
        async for _ in stream:
            pass

Compact

Compaction summarizes older conversation history to free context-window space.

Python
outcome = await session.compact(instructions="Keep decisions and unresolved failures.")
 
async with outcome.session as compacted:
    print(outcome.removed_count)

Rewind

Rewind returns the conversation to an earlier message and can restore or delete files changed since.

Python
info = await session.rewind_info(message_id)
 
outcome = await session.rewind(
    message_id,
    restore=info.available_files,
    delete=info.created_files,
    title="Before the failed change",
)
 
async with outcome.session as rewound:
    print(outcome.restored_count, outcome.deleted_count)
    print(outcome.failed_restore_count, outcome.failed_delete_count)

info.evicted_files explains files that cannot be restored.

Lifecycle contracts

MethodArgumentsReturns
fork()title, tagsOpened Session
compact()instructionsCompactOutcome
rewind_info()message_idRewindInfo
rewind()message_id, restore, delete, titleRewindOutcome
TypeFields
CompactOutcomesession, removed_count
RewindInfoavailable_files, created_files, evicted_files
RewindFileSnapshotfile_path, content_hash, size
RewindFileCreationfile_path
RewindEvictedFilefile_path, reason
RewindOutcomesession, restored_count, deleted_count, failed_restore_count, failed_delete_count

Successor ownership

After a successful replacement:

  • the returned successor owns the connection and runtime resources
  • the source session becomes retired
  • id, cwd, and settings remain readable on the source
  • active methods on the source raise SessionReplacedError
  • closing the source is a no-op

Replacing a session with an active turn raises SessionBusyError.

Only one replacement may run at a time. open() and another replacement raise SessionBusyError while replacement is active. close() racing a replacement waits; on success it closes the successor, and on rollback it closes the source.

Cancelling a replacement before handoff restores the source to open state. If cancellation arrives after a successor is created, the SDK reloads the source with its attached policies and retires the detached successor. If source restoration fails, the SDK closes the connection rather than leaving an ambiguous owner.

Spec mode

Spec mode lets Droid inspect a codebase and propose a plan without changing files.

Enter Spec Mode

Python
await session.enter_spec(
    model="model-id",
    reasoning_effort=ReasoningEffort.HIGH,
)

Start directly in Spec mode with SessionConfig(mode=Mode.SPEC).

Leave without approving a plan

Python
await session.leave_spec()

This changes the mode only. It does not approve the plan or start implementation.

Approve a plan

Plan approval arrives through the permission handler:

Python
def approve(request: PermissionRequest) -> PermissionResponse:
    if request.plan:
        print(request.plan.text)
        return request.respond(ToolConfirmationOutcome.PROCEED_ONCE)
    return request.respond(ToolConfirmationOutcome.CANCEL)

Return any offered PROCEED_NEW_SESSION* outcome to hand implementation to a new session. Return PROCEED_EDIT with edited_spec_content to revise the plan. CANCEL ends the turn with an interrupted result.

Mode contract

APIArgumentsReturns
SessionConfig(mode=...)Mode.AUTO or Mode.SPECSessionConfig
enter_spec()model, reasoning_effortUpdateSettingsResult
leave_spec()NoneUpdateSettingsResult

Entering or leaving Spec mode only changes settings. Plan approval is an ExitSpecModeAction permission request, and only an offered ToolConfirmationOutcome is valid.

Observability and advanced APIs

Observability

Python
from droid_sdk import Runtime, run
from droid_sdk.observability import LogEvent, Observability
 
 
class PrintLogger:
    def log(self, event: LogEvent) -> None:
        print(event.level, event.name, event.message)
 
 
runtime = Runtime(observability=Observability(logger=PrintLogger()))
 
result = await run("Check repository status.", runtime=runtime)

Logger, metric, and trace-context sinks are synchronous and best-effort. Sink failures never fail a Droid operation.

Events exclude prompts, messages, thinking, tool inputs and results, file contents, raw process output, stack traces, and credentials.

Observability schemas

TypeFields or methods
Observabilitylogger, metrics, tracing
Loggerlog(event: LogEvent) -> None
LogEventlevel, name, message, attributes, error
SerializedErrorname, message, code
MetricSinkrecord(event: MetricEvent) -> None
MetricEventname, kind, value, unit, attributes
TraceContextProviderinject(carrier: TraceContext) -> None
TraceContexttraceparent, tracestate

attributes is Mapping[str, str | int | float | bool | None]. Log levels are "debug", "info", "warn", and "error". Metric kinds are "counter" and "histogram". TraceContext is deliberately mutable so tracing providers can inject values into it.

Custom runtime

Runtime holds process configuration:

Python
runtime = Runtime(
    executable=Path("/opt/factory/bin/droid"),
    args=["--flag"],
    env={"EXAMPLE": "value"},
)

Environment entries extend the current process environment when the SDK starts Droid.

Runtime schema

FieldType
executablestr | Path | None
argsSequence[str]
envMapping[str, str]
observabilityObservability | None

Examples

Run commands from the repository root:

ExampleCommandExpected result
Attachmentsuv run python examples/attachments.pyLive image, text, and PDF turn
Factory Routeruv run python examples/factory_router.pyLive routed turns with per-response model IDs
Interaction helpersuv run python examples/interaction_helpers.pyOffline typed responses
Interactionsuv run python examples/interactions.pyLive permission/question turn
Interactive sessionuv run python examples/interactive_session.pyTwo live turns sharing history
Saved sessionsuv run python examples/list_saved_sessions.pyLocal saved-session count
Observabilityuv run python examples/observability.pyOffline isolated sink counts
One-shot runuv run python examples/one_shot.pyLive one-turn result
Resumeuv run python examples/resume_session.py --session-id IDLive resumed turn
SDK MCPuv run python examples/sdk_mcp.pyLive in-process MCP result
Session operationsuv run python examples/session_operations.pyLive settings, discovery, fork, and compact
Stream eventsuv run python examples/stream_events.pyLive tour of every stream event type
Structured outputuv run python examples/structured_output_model.pyLive validated model output

Live examples require an authenticated local Droid CLI or FACTORY_API_KEY; every model call uses a finite timeout.

API reference

Root constants and supporting types

ExportContract
__version__Installed package version as str
MAX_ATTACHMENT_BYTESGeneral attachment limit, 5 * 1024 * 1024
MAX_PDF_ATTACHMENT_BYTESPDF limit, 3 * 1024 * 1024
ImageMediaTypeSupported image MIME literal union
JsonValue, JsonObjectMutable JSON input/output aliases
FrozenJsonValue, FrozenJsonObjectRecursively immutable JSON aliases
ApplyPatchFilePer-file patch details documented above
AskUserParseErrormessage, line
SandboxSettingsenabled, mode
SessionSettingsUpdatePartial settings notification documented above

Top-level functions

APIReturnsPurpose
run(prompt, **options)RunResult[T]Run one turn
list_sessions(**filters)list[SavedSession]List saved sessions
run() argumentType
promptstr
cwdstr | Path | None
modelstr | None
reasoning_effortReasoningEffort | None
imagesSequence[Image]
filesSequence[Document]
outputtype[BaseModel] | JsonSchema | None
timeoutfloat | None
configSessionConfig | None
interactionsInteractionHandlers | None
runtimeRuntime | None
api_keystr | None

Session

MemberReturns
Session(...)Lazy Session
Session.resume(id, ...)Lazy Session
open()None
close()None
idstr
cwdPath | None
settingsSessionSettings
stream()RunStream[T, E]
interrupt()None
update_settings()UpdateSettingsResult
rename()None
on_notification()Callable[[], None]
list_tools()list[ToolInfo]
list_skills()SkillsResult
enable_skill() / disable_skill()SkillMutationResult
list_mcp_servers()McpServersResult
list_mcp_tools()list[McpToolInfo]
MCP mutation methodsMcpMutationResult
context()ContextUsage
fork()Opened Session
compact()CompactOutcome
rewind_info()RewindInfo
rewind()RewindOutcome
enter_spec() / leave_spec()UpdateSettingsResult

RunStream

MemberReturns
Async iterationStreamMessage[T] or StreamEvent[T] values
resultCached RunResult[T]
aclose()None

Main enums

EnumValues
ModeAUTO, SPEC
AutonomyOFF, LOW, MEDIUM, HIGH
ReasoningEffortSee supported values below
ToolCategoryREAD, EDIT, EXECUTE, OTHER
ToolConfirmationTypeEDIT, EXECUTE, CREATE, ASK_USER, EXIT_SPEC_MODE, APPLY_PATCH, MCP_TOOL, SANDBOX_VIOLATION, DROID_SHIELD_VIOLATION
ToolConfirmationOutcomePermission outcomes offered by Droid
WorkingStateIDLE, THINKING, STREAMING_ASSISTANT_MESSAGE, WAITING_FOR_TOOL_CONFIRMATION, EXECUTING_TOOL, COMPACTING_CONVERSATION
ContextAccuracyEXACT, ESTIMATED
McpServerTypeSTDIO, HTTP, SSE
McpServerStatusCONNECTING, CONNECTED, DISCONNECTED, FAILED, DISABLED
McpAuthOutcomeSUCCESS, CANCELLED, FAILED
OAuthTokenEndpointAuthMethodNONE, CLIENT_SECRET_BASIC, CLIENT_SECRET_POST
SessionPlatformSLACK, WEB, API, SESSIONS_API, JIRA, LINEAR, MICROSOFT_TEAMS, READINESS_REMEDIATION, READINESS_EVALUATION, AUTOMATION, WIKI_GENERATION, WIKI_CI_SETUP, TUI, DESKTOP, ACP, UNKNOWN
SandboxOperationREAD, WRITE, NETWORK, TOOL
SandboxViolationTypeFILESYSTEM_READ, FILESYSTEM_WRITE, NETWORK, TOOL
SandboxViolationReasonDENY_LIST, NOT_ALLOWED
ErrorTypeCONNECTION_ERROR, PROTOCOL_ERROR, SESSION_ERROR, TIMEOUT_ERROR, DROID_CLIENT_ERROR, PROCESS_EXIT_ERROR, ERROR

ReasoningEffort defines NONE, DYNAMIC, OFF, MINIMAL, LOW, MEDIUM, HIGH, EXTRA_HIGH, and MAX. Model and tool IDs are plain strings, not enums.

On the wire, these enums use the following values:

ToolCategory: read, edit, execute, other
ToolConfirmationType: edit, exec, create, ask_user, exit_spec_mode,
  apply_patch, mcp_tool, sandbox_violation, droid_shield_violation
OAuthTokenEndpointAuthMethod: none, client_secret_basic, client_secret_post
SandboxOperation: read, write, network, tool
SandboxViolationType: filesystem-read, filesystem-write, network, tool
SandboxViolationReason: deny-list, not-allowed
SessionPlatform: slack, web, api, sessions_api, jira, linear,
  microsoft-teams, readiness-remediation, readiness-evaluation, automation,
  wiki-generation, wiki-ci-setup, tui, desktop, acp, unknown

Exceptions

All SDK-defined exceptions derive from DroidError. Python built-ins and asyncio.CancelledError do not.

ExceptionMeaning
RunTimeoutErrorThe turn exceeded its deadline
StreamIncompleteErrorA stream result was read before completion
InvalidAttachmentErrorA local attachment was invalid
SessionNotOpenErrorAn operation required an opened session
SessionBusyErrorThe session already had an active turn or replacement in progress
SessionClosedErrorThe session was closed
SessionReplacedErrorA successor retired the source session
SessionReplacementErrorSuccessor load or source restore failed
SessionNotFoundErrorA saved session ID was not found
InvalidWorkingDirectoryErrorA working directory is unavailable
DroidConnectionErrorThe local connection failed
DroidProcessErrorThe Droid process exited unexpectedly
DroidProtocolErrorProtocol negotiation or validation failed

Exception constructor metadata is public:

ExceptionAdditional constructor fields
RunTimeoutError(message, ...)request_id, method, timeout_duration
SessionReplacedError(session_id, replacement_session_id)both session IDs
SessionReplacementError(session_id, replacement_session_id, ...)both IDs, rollback_error, rollback_failed
SessionNotFoundError(session_id)session_id
InvalidWorkingDirectoryError(cwd, message=None)cwd
DroidConnectionError(message, ...)cwd, exec_path
DroidProcessError(message, ...)exit_code, signal
DroidProtocolError(message, ...)code, data

Detailed session contracts

Session construction contract

Session(...) accepts:

ArgumentType
cwdstr | Path | None
modelstr | None
reasoning_effortReasoningEffort | None
configSessionConfig | None
interactionsInteractionHandlers | None
runtimeRuntime | None
api_keystr | None

SessionConfig fields:

FieldType
modeMode | None
autonomyAutonomy | None
spec_modelstr | None
spec_reasoning_effortReasoningEffort | None
mcp_serversSequence[McpServerConfig]
machine_idstr | None
tagsSequence[SessionTag]
session_sourceSessionSource | None
auto_reject_permission_requestsbool | None
disable_builtin_skillsbool | None
additional_toolsIterable[str] | None
enabled_toolsIterable[str] | None
disabled_toolsIterable[str] | None
restrict_toolsIterable[str] | None

Session.resume(session_id, ...) accepts only values that can be reattached:

ArgumentType
session_idstr
interactionsInteractionHandlers | None
mcp_serversSequence[McpServerConfig]
runtimeRuntime | None
api_keystr | None
disabled_toolsIterable[str] | None
auto_reject_permission_requestsbool | None
disable_builtin_skillsbool | None
session_sourceSessionSource | None

Session state schemas

TypeFields
SessionSettingsmodel, reasoning_effort, mode, autonomy, spec_model, spec_reasoning_effort, tags, sandbox, additional_tools, enabled_tools, disabled_tools, restrict_tools
SessionSettingsUpdatemodel, reasoning_effort, mode, autonomy, spec_model, spec_reasoning_effort, tags, additional_tools, enabled_tools, disabled_tools, restrict_tools, compaction_threshold_check_enabled
SandboxSettingsenabled: bool, mode: str | None = None
SessionTagname, metadata
SavedSessionid, title, owner, message_count, modified_at, created_at, cwd, is_favorite

Every SessionSettingsUpdate field defaults to None. Its field types match the update_settings() table above.

list_sessions() returns list[SavedSession]. Its filters are cwd, all_workspaces, and limit.

on_notification(callback, type=None) passes Mapping[str, object] to the callback and returns an unsubscribe function.

SessionSource requires platform: SessionPlatform; every other attribution field is optional and defaults to None. Required combinations are validated when converted to the wire protocol.

Known limitations

  • The SDK runs local Droid subprocess sessions only.
  • The API is asyncio-only.
  • Hooks are configured through Droid files, not Python callbacks.
  • Image URLs are not supported by the local runtime.
  • One session can run one turn at a time.