Skip to content

Latest commit

History

History
1815 lines (1396 loc) · 57.3 KB

File metadata and controls

1815 lines (1396 loc) · 57.3 KB

Droid SDK for Python

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

Overview

The SDK runs a local droid subprocess and exposes two ways to run a turn:

GoalAPI
Run one turn and return its resultawait run(...)
Run turns in an existing sessionsession.stream(...)

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().

Install and authenticate

pip install droid-sdk

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 turn

importasynciofromdroid_sdkimportrunasyncdefmain() ->None:
result=awaitrun("Summarize this repository.")
ifresult.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

importasynciofrompathlibimportPathfromdroid_sdkimportSessionasyncdefrun_turn(session: Session, prompt: str) ->str:
asyncwithsession.stream(prompt) asstream:
asyncfor_instream:
passresult=stream.resultifnotresult.success:
raiseRuntimeError(result.subtype)
returnresult.textasyncdefmain() ->None:
asyncwithSession(cwd=Path.cwd()) assession:
print(awaitrun_turn(session, "What does this project do?"))
print(awaitrun_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.

Stream partial events

fromdroid_sdkimportSession, TextDeltaasyncwithSession() assession:
asyncwithsession.stream(
"Explain the failing test.",
include_partial_messages=True,
) asstream:
asyncforeventinstream:
ifisinstance(event, TextDelta):
print(event.text, end="", flush=True)
print()
ifnotstream.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.

Core contract

Sessions and turns

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

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

Results and exceptions

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

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 dataclasses.

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]

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

fromdroid_sdkimportReasoningEffort, runresult=awaitrun(
"Review this repository.",
model="model-id",
reasoning_effort=ReasoningEffort.HIGH,
)

Omit reasoning_effort to use the model default.

Change the model for later turns:

awaitsession.update_settings(
model="model-id",
reasoning_effort=ReasoningEffort.HIGH,
)

See Update settings for the full update_settings() contract.

Use the Factory Router

The model ID auto selects the Factory Router, which routes each task 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.

fromdroid_sdkimportrunresult=awaitrun("Review this repository.", model="auto")

Pin a session to the router the same way:

asyncwithSession(model="auto") assession:
...

Move a live session onto the router:

awaitsession.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():

fromcollections.abcimportMappingdefreport_routing(notification: Mapping[str, object]) ->None:
message=notification.get("message")
ifisinstance(message, Mapping) andmessage.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

fromdroid_sdkimportMode, ReasoningEffort, Session, SessionConfigconfig=SessionConfig(
mode=Mode.SPEC,
spec_model="model-id",
spec_reasoning_effort=ReasoningEffort.HIGH,
)
asyncwithSession(model="model-id", config=config) assession:
asyncwithsession.stream("Draft an implementation plan.") asstream:
asyncfor_instream:
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 Modes.

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.

Sessions

Use a session when turns share history.

Open a session

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

asyncwithSession(
cwd=Path.cwd(),
model="auto",
) assession:
asyncwithsession.stream("Review the project.") asstream:
asyncformessageinstream:
handle(message)

model="auto" selects the Factory Router.

Configure behavior and attach handlers with SessionConfig and InteractionHandlers:

fromdroid_sdkimport (
Autonomy,
InteractionHandlers,
PermissionRequest,
PermissionResponse,
SessionConfig,
ToolConfirmationOutcome,
)
defapprove(request: PermissionRequest) ->PermissionResponse:
returnrequest.respond(ToolConfirmationOutcome.PROCEED_ONCE)
config=SessionConfig(
autonomy=Autonomy.LOW,
disabled_tools={"Execute"}, # any iterable of tool IDs works heredisable_builtin_skills=True,
)
asyncwithSession(
config=config,
interactions=InteractionHandlers(on_permission=approve),
) assession:
...

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

asyncwithSession.resume(
session_id,
interactions=InteractionHandlers(on_question=answer),
disabled_tools={"Execute"},
) assession:
asyncwithsession.stream("Continue the previous task.") asstream:
asyncfor_instream:
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.

What persists

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

Open and close manually

session=Session()
awaitsession.open()
try:
asyncwithsession.stream("Review the project.") asstream:
asyncfor_instream:
passfinally:
awaitsession.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.

Read session state

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 settings

awaitsession.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

awaitsession.rename("Authentication review")

rename(title: str) returns None.

Subscribe to raw notifications

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.

List saved sessions

fromdroid_sdkimportlist_sessionsforsavedinawaitlist_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.

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.

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 by default

fromdroid_sdkimportAssistantMessageasyncwithsession.stream("Run the tests.") asstream:
asyncformessageinstream:
ifisinstance(message, AssistantMessage):
print(message.text)

Default stream types

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.

Include partial events

fromdroid_sdkimportRunResult, TextDeltaasyncwithsession.stream(
"Explain the failing test.",
include_partial_messages=True,
) asstream:
asyncforeventinstream:
ifisinstance(event, TextDelta):
print(event.text, end="", flush=True)
elifisinstance(event, RunResult) andnotevent.success:
print(f"\nTurn ended: {event.subtype}")

Partial stream types

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

fromdroid_sdkimportAssistantMessage, ConversationMessage, UserMessagecompleted: list[ConversationMessage] = []
asyncwithsession.stream("Explain the failing test.") asstream:
asyncforeventinstream:
ifisinstance(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().

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.

Read the result

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

fromdroid_sdkimportRunResultasyncwithsession.stream("Run the tests.") asstream:
asyncformessageinstream:
ifisinstance(message, RunResult):
print(message.subtype)
result=stream.resultifresult.success:
print(result.text)

Reading stream.result before completion raises StreamIncompleteError.

Result types

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

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

ifresult.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:

context=awaitsession.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

Failures and exceptions

asyncwithsession.stream("Run the tests.") asstream:
asyncfor_instream:
passresult=stream.resultifresult.subtype=="interrupted":
print("The turn was interrupted.")
elifresult.subtypein ("error_during_execution", "error_structured_output"):
print(result.error.messageifresult.errorelseresult.subtype)

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

Concurrency

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

Different sessions may run concurrently.

Timeout

asyncwithsession.stream("Perform a long review.", timeout=60) asstream:
asyncforeventinstream:
handle(event)

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

Interrupt a turn

Another task may stop active work:

awaitsession.interrupt()

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

Cancellation and early exit

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:

asyncwithsession.stream("Inspect every test.") asstream:
asyncforeventinstream:
ifshould_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.

Inputs and outputs

Images and files

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

fromdroid_sdkimportDocument, Image, runresult=awaitrun(
"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.

Return a Pydantic model

fromtypingimportLiteralfrompydanticimportBaseModelfromdroid_sdkimportRunSuccess, runclassFinding(BaseModel):
severity: Literal["low", "medium", "high"]
message: strclassReview(BaseModel):
summary: strfindings: list[Finding]
result=awaitrun(
"Review the authentication code.",
output=Review,
)
ifisinstance(result, RunSuccess) andresult.outputisnotNone:
print(result.output.summary) # success guarantees output when requestedelifresult.output_validation_errorisnotNone:
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

fromdroid_sdkimportJsonObject, JsonSchema, RunResult, runschema=JsonSchema(
{
"type": "object",
"properties": {"summary": {"type": "string"}},
"required": ["summary"],
}
)
result: RunResult[JsonObject] =awaitrun(
"Summarize the repository.",
output=schema,
)
ifresult.outputisnotNone:
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.

JsonValue=bool|int|float|str|list["JsonValue"] |dict[str, "JsonValue"] |NoneJsonObject=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:

config=SessionConfig(autonomy=Autonomy.LOW)

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

Handle permission requests

fromdroid_sdkimport (
InteractionHandlers,
PermissionRequest,
PermissionResponse,
Session,
ToolConfirmationOutcome,
)
fromdroid_sdk.permissionsimportCreateFiledefapprove(request: PermissionRequest) ->PermissionResponse:
ifrequest.actionsandall(
isinstance(action, CreateFile) foractioninrequest.actions
):
returnrequest.respond(ToolConfirmationOutcome.PROCEED_ONCE)
returnrequest.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.

Answer questions from Droid

fromdroid_sdkimportQuestionRequest, QuestionResponsedefanswer(request: QuestionRequest) ->QuestionResponse:
answers= [
question.answer(question.options[0] ifquestion.optionselse"none")
forquestioninrequest.questions
]
returnrequest.submit(answers)

Cancel the questionnaire:

returnrequest.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

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:

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.

Control native tools

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.
fortoolinawaitsession.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

Tools and 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

skills=awaitsession.list_skills()
forskillinskills.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:

awaitsession.enable_skill("review", scope="project")
awaitsession.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:

fromdroid_sdk.mcpimportcreate_sdk_mcp_server, tool@tool("lookup_owner", "Return the owner of a repository file.")asyncdeflookup_owner(path: str) ->str:
returnf"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:

fromdroid_sdkimportHttpMcpServerConfig, StdioMcpServerConfigconfig=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:

servers=awaitsession.list_mcp_servers()
tools=awaitsession.list_mcp_tools()
print(servers.summary)
forserverinservers.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

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.

fork=awaitsession.fork(
title="Alternative approach",
tags=[SessionTag(name="experiment")],
)
asyncwithfork:
asyncwithfork.stream("Try the other strategy.") asstream:
asyncfor_instream:
pass

Compact

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

outcome=awaitsession.compact(instructions="Keep decisions and unresolved failures.")
asyncwithoutcome.sessionascompacted:
print(outcome.removed_count)

Rewind

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

info=awaitsession.rewind_info(message_id)
outcome=awaitsession.rewind(
message_id,
restore=info.available_files,
delete=info.created_files,
title="Before the failed change",
)
asyncwithoutcome.sessionasrewound:
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.

Modes

Spec mode

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

Enter Spec mode

awaitsession.enter_spec(
model="model-id",
reasoning_effort=ReasoningEffort.HIGH,
)

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

Leave without approving

awaitsession.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:

defapprove(request: PermissionRequest) ->PermissionResponse:
ifrequest.plan:
print(request.plan.text)
returnrequest.respond(ToolConfirmationOutcome.PROCEED_ONCE)
returnrequest.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.

Operations

Observability

fromdroid_sdkimportRuntime, runfromdroid_sdk.observabilityimportLogEvent, ObservabilityclassPrintLogger:
deflog(self, event: LogEvent) ->None:
print(event.level, event.name, event.message)
runtime=Runtime(observability=Observability(logger=PrintLogger()))
result=awaitrun("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:

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

API index

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

Runnable 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.

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.