Skip to main content

Doctronic Python SDK

Python client for the Doctronic API. Sync and async clients, typed models generated from the OpenAPI contract, typed chat stream events, safe retries, and webhook verification. For Node.js, use the TypeScript SDK (@doctronic-ai/sdk on npm), which covers the same operations.

Requires Python 3.10 or later; CI tests 3.10 to 3.15. Runtime dependencies: httpx and pydantic v2.

Install

pip install doctronic
# or
uv add doctronic

Quickstart

Stream an assistant message in staging, using a synthetic patient. You need a staging API token; see get access. Run this from a terminal or your backend, and keep the token there.

export DOCTRONIC_API_TOKEN="replace-with-your-staging-token"
import uuid

from doctronic import Doctronic
from doctronic.types import MessageContentEvent, MessageStartEvent, MessageStopEvent

client = Doctronic()  # Reads DOCTRONIC_API_TOKEN. Defaults to staging.

# 1. Create a user, and save user.id on the patient record in your system.
user = client.users.create(
    first_name="Avery",
    last_name="Chen",
    date_of_birth="1990-06-15",
    # Email and phone number must be unique per user, so each run uses a new email and no phone.
    email=f"avery+quickstart-{uuid.uuid4()}@example.com",
    sex="female",
)
patient = client.for_user(user.id)

# 2. Create a chat.
chat = patient.chats.create(timezone="America/New_York")

# 3. Stream a patient turn.
messages: dict[str, dict[str, str]] = {}
with patient.chats.stream(
    chat.id,
    user_input="I have had a sore throat and fever since yesterday.",
    timezone="America/New_York",
) as stream:
    for event in stream:
        if isinstance(event, MessageStartEvent):
            messages[event.message_id] = {"role": event.role, "text": ""}
        elif isinstance(event, MessageContentEvent):
            messages[event.message_id]["text"] += event.content
        elif isinstance(event, MessageStopEvent):
            print(messages[event.message_id])
        else:
            print(event.type, event)  # guardian, conversation.end, error, and any new event

examples/quickstart.py is the same program. A turn can emit a guardian event that your product must act on; read Safety before you show the stream to patients.

Async

AsyncDoctronic has the same methods. Await each call, open the stream with async with, and iterate it with async for:

import asyncio

from doctronic import AsyncDoctronic


async def main() -> None:
    async with AsyncDoctronic() as client:
        patient = client.for_user("mem_01K1EXAMPLE00000000000000")
        chat = await patient.chats.create(timezone="America/New_York")
        async with patient.chats.stream(chat.id, user_input="Hello") as stream:
            async for event in stream:
                print(event.type)


asyncio.run(main())

await patient.chats.stream(...) also works, and returns the AsyncChatStream for you to close(). So does async with await ..., the form 0.1.0 needed.

Configuration

client = Doctronic(
    api_token="...",  # Default: DOCTRONIC_API_TOKEN. Required.
    environment="production",  # "staging" or "production". Default: DOCTRONIC_ENVIRONMENT, then "staging".
    base_url=None,  # Overrides the environment's base URL.
    timeout=60.0,  # Seconds, or an httpx.Timeout. See below.
    max_retries=2,  # Retries for reads and idempotent mutations.
    http_client=None,  # Your own httpx.Client (httpx.AsyncClient for AsyncDoctronic).
)
Environment Base URL
staging https://staging-partners.doctronic.ai/api/v1
production https://partners.doctronic.ai/api/v1

timeout applies to each attempt, with httpx's semantics: connecting, sending, and each wait for data get up to that long. It isn't a wall-clock limit on the whole response. For a stream it covers the wait until the stream starts; after that, gaps between events never time out.

Constructing a client without a token raises DoctronicError. The SDK never logs the token or puts it in an error message. Keep it on your backend: never in browser code, mobile apps, or model prompts.

User scope

The token authenticates your organization; X-Doctronic-User-ID selects the patient. Two operations take the token alone, and they live on the base client:

user = client.users.create(first_name="Avery")
pharmacies = client.pharmacy.search(latitude=40.7608, longitude=-111.891)

Everything else acts on one patient. for_user() returns a client that sends the header on every request, so a user-scoped call can't go out without a user:

patient = client.for_user(user.id)
patient.users.update(phone_number="+15555550100", email=None)  # None clears; omitted keeps.
Resource Methods
client.users create
client.pharmacy search
patient.users retrieve, update
patient.chats create, list, retrieve, messages, stream
patient.appointments available_slots, create_scheduled, create_asap, list, retrieve, result, cancel, reschedule
patient.address retrieve, set
patient.pharmacy set
patient.coverage set
patient.artifacts retrieve, wait_until_ready

Your backend decides which patient a request is for. Derive the user ID from the patient your system authenticated, never from an ID a browser or app sent you.

Models

Methods return the data of the {code, message, data} envelope as pydantic models from doctronic.types. Each model has its contract name, such as UserCreatedResponse, and the short name the TypeScript SDK uses, such as User. Attributes are snake_case; the camelCase wire names are aliases, so model.model_dump(by_alias=True) gives the wire shape back.

Responses from a newer API don't fail, so you can ignore what you don't know yet:

  • Fields the contract doesn't list yet are kept, in model.model_extra.
  • Enums accept values this version doesn't list. A new appointment status arrives as an AppointmentStatus that equals the string, such as "on_hold"; the known values are the usual members. Compare with the members you handle and treat anything else as unknown.
  • Data that doesn't match the contract, such as a missing field or a value of another type, is returned rather than raised: fields that match are typed, and the rest are kept as sent. Missing required fields are None; optional fields keep their defaults. The SDK logs a warning on the doctronic logger with the request ID and the field names, never their values.
Short name Contract model
User UserCreatedResponse
Chat, ChatPage ChatResponse, PageChatResponse
ChatMessage, ChatMessagePage MessageResponse, PageMessageResponse
Appointment, AppointmentPage, AppointmentStatus AppointmentResponse, PageAppointmentResponse, AppointmentAPIStatus
ScheduledAppointment, AsapAppointment ScheduledAppointmentResponse, AsapAppointmentResponse
AppointmentResult, AvailableSlots AppointmentResultResponse, AvailableSlotsResponse
Address, UsState AddressResponse, State
PharmacySearchResults PharmacySearchResponse
Artifact ArtifactResponse: one of SoapNoteArtifactResponse, ClinicianNoteArtifactResponse, LongSummaryArtifactResponse, ShortSummaryArtifactResponse
appointment = patient.appointments.retrieve("dc_01K1EXAMPLE00000000000000")
appointment.waiting_room_url
appointment.model_dump(mode="json", by_alias=True)  # {"waitingRoomUrl": ..., ...}

Streaming

chats.stream() sends one patient turn and returns the reply as typed events: a plain iterator on Doctronic, an async iterator on AsyncDoctronic. Use it as a context manager, or call close(), to release the connection when you stop early. The client's timeout covers the wait until the stream starts; after that the stream stays open as long as the assistant replies.

Give the patient a cancel action that calls close(). It's safe to call from another thread or task: the code reading the stream then raises APIUserAbortError, and so does iterating any stream closed before its end, so a cancelled turn never looks finished. On AsyncDoctronic, cancelling the reading task also releases the connection; the asyncio.CancelledError propagates unchanged.

Event Class Fields
message.start MessageStartEvent role, message_id, message_timestamp
message.content MessageContentEvent message_id, content
message.stop MessageStopEvent message_id
guardian GuardianEvent guardian_id, message
conversation.offtopic ConversationOfftopicEvent message_id
conversation.end ConversationEndEvent message_id, reason
skill_code.cta SkillCodeCtaEvent skill_code, widget, text
ai_consultation.complete AiConsultationCompleteEvent artifacts.long_summary, .short_summary, .soap_note
error StreamErrorEvent error_code, message, retry_after_seconds
Anything else UnknownChatStreamEvent type, data (the parsed payload)
  • Unknown events never raise. New event names arrive as UnknownChatStreamEvent; ignore the ones you don't use yet.
  • An error event is yielded, not raised. Handle it apart from message text.
  • Errors before the stream starts raise: ConflictError for error.partner.chat_stream_in_progress, ServiceUnavailableError for error.partner.chat_stream_unavailable. A 200 that isn't text/event-stream raises APIResponseValidationError rather than reading as an empty turn.
  • The stream is never retried. It takes no idempotency key, and the first attempt may already have produced a reply. After a dropped connection (APIConnectionError) or an error event, read chats.messages(chat_id) before you let the patient resend the turn.

To relay events to your frontend, serialize them in the wire shape: event.model_dump(by_alias=True, exclude_none=True).

Assemble messages

final_messages() reads the rest of the stream and returns each message assembled by message_id, with the role from its message.start, plus every other event in arrival order. It includes events you already iterated over.

with patient.chats.stream(chat.id, user_input="My fever is 101.5 F.") as stream:
    final = stream.final_messages()

for message in final.messages:
    print(message.role, message.content, message.complete)  # complete: its message.stop arrived
for event in final.control_events:
    print(event.type)  # guardian, skill_code.cta, ai_consultation.complete, ...

Consultation documents

artifacts.retrieve() returns the document, or None while it's still generating (202). wait_until_ready() polls with exponential backoff from 1 to 16 seconds, for up to timeout seconds (default 120):

from doctronic.types import AiConsultationCompleteEvent

for event in final.control_events:
    if isinstance(event, AiConsultationCompleteEvent) and event.artifacts.short_summary:
        summary = patient.artifacts.wait_until_ready(event.artifacts.short_summary, timeout=120)
        assert summary.content.chat_id == chat.id
        print(summary.content.body)  # Markdown. Sanitize it before you render it.

timeout must be positive, and one deadline covers the whole wait: every poll, the retries inside each poll, and the waits between them. Each request's timeouts are capped by the time left. It stops with NotFoundError on 404, InternalServerError on 500 (generation failed permanently), and ArtifactNotReadyError at the deadline. On AsyncDoctronic, cancel the task to stop early.

appointments.result() works the same way: it returns None while the result isn't available yet.

Idempotency and retries

The SDK sends an Idempotency-Key on the 11 operations whose contract declares one: every mutation except chats.stream(). It generates one key per call and reuses the same key and body on every retry of that call. Pass your own key to make a change safe to replay later, for example after your process restarts:

patient.appointments.create_scheduled(
    chat_id=chat.id,
    starts_at=slot,
    location={"state": "NY"},
    idempotency_key=booking.id,  # One key per intended change; up to 255 characters.
)

Retries follow the retry checklist:

Failure Reads (GET) Idempotent mutations chats.stream()
Network error or timeout Retried Retried Never
429, 503 Retried Retried Never
Other 5xx Retried, unless the contract documents the status, like 500 from artifacts.retrieve() Not retried Never
409 whose code isn't final for the operation (see below) Not retried Retried Never
Other 4xx Not retried Not retried Never

A 409 code the contract documents for the operation, such as error.appointments.slot_unavailable, is final and isn't retried, unless the contract lists it in x-doctronic-retryable-codes as safe to retry with the same key, such as error.idempotency.concurrent_request. A 409 without a code, or with one the contract doesn't document, is an idempotency conflict and is retried with the same key.

Retries back off exponentially from 0.5 to 8 seconds, plus up to 25% jitter. A Retry-After header replaces the backoff; one over 60 seconds ends the retries so you decide when to try again. max_retries (default 2) caps the attempts; max_retries=0 turns retries off. An error response cut off mid-body keeps its status and Retry-After, so the rules above still apply. A success response cut off mid-body counts as a network error.

Errors

Every error subclasses DoctronicError. Responses with an error status raise an APIError subclass; branch on status and code, and show or log message.

Status Class
400, 422 BadRequestError
401 AuthenticationError
403 PermissionDeniedError
404 NotFoundError
409 ConflictError
429 RateLimitError
503 ServiceUnavailableError
Other 5xx InternalServerError
No response APIConnectionError, and APITimeoutError for timeouts
Stream closed before its end APIUserAbortError
from doctronic import BadRequestError, ConflictError

try:
    patient.appointments.create_scheduled(chat_id=chat.id, starts_at=slot, location={"state": "NY"})
except ConflictError as err:
    if err.code == "error.appointments.slot_unavailable":
        ...  # Refresh availability and ask the patient to choose again.
except BadRequestError as err:
    for field_error in err.field_errors:  # Set for error.validation_error, else empty.
        print(field_error.field, field_error.code)

APIError carries status, code (None when the body has none), message, data, headers, retry_after, and request_id (from X-Request-ID). Keep request_id with your record of every failure so Doctronic can trace it.

A 403 is error.auth.insufficient_scope: the API key lacks a scope. InsufficientScopeData.model_validate(err.data).required_scope names it. A user your organization can't act on is a 404.

A success response the SDK can't use raises APIResponseValidationError, a subclass of APIError that is never retried: a body that isn't JSON, a missing or null data for an operation that returns data, or a 200 stream that isn't text/event-stream. Data that only differs from the contract doesn't raise; see Models above.

Raw responses

Prefix a call with with_raw_response to get an APIResponse with the status, headers, and request ID. parse() returns what the method normally returns.

raw = client.with_raw_response.users.create(first_name="Avery")
raw.status_code  # 201
raw.request_id  # X-Request-ID
raw.idempotency_key  # The key the SDK sent
raw.idempotency_replayed  # True when the API replayed a stored response
user = raw.parse()

raw = patient.chats.with_raw_response.retrieve(chat.id)  # Also available per resource.

Webhooks

Verify each delivery against the raw request body before you parse it. verify_webhook() checks the X-Doctronic-Signature HMAC-SHA256 in constant time and returns a typed event. It raises WebhookVerificationError when the signature doesn't match, when the signed body isn't a valid event, and when the secret is empty, so a missing secret rejects every delivery.

With FastAPI:

import os

from fastapi import FastAPI, Request, Response

from doctronic import WEBHOOK_SIGNATURE_HEADER, WebhookVerificationError, verify_webhook
from doctronic.types import AppointmentWaitingRoomReadyEvent, ArtifactReadyEvent

app = FastAPI()
# Your app's own setting. The SDK doesn't read environment variables for webhooks.
WEBHOOK_SECRET = os.environ["DOCTRONIC_WEBHOOK_SECRET"]


@app.post("/webhooks/doctronic")
async def doctronic_webhook(request: Request) -> Response:
    try:
        event = verify_webhook(
            await request.body(), request.headers.get(WEBHOOK_SIGNATURE_HEADER), WEBHOOK_SECRET
        )
    except WebhookVerificationError:
        return Response(status_code=400)
    # seen_before, store_waiting_room_url, and enqueue_artifact_fetch stand for your own code.
    if not seen_before(event.event_id):  # Deduplicate on event_id.
        if isinstance(event, AppointmentWaitingRoomReadyEvent):
            store_waiting_room_url(event.data.appointment_id, event.data.waiting_room_url)
        elif isinstance(event, ArtifactReadyEvent):
            enqueue_artifact_fetch(event.data.artifact_id)
    return Response(status_code=200)  # Acknowledge unknown events too.

WEBHOOK_SIGNATURE_HEADER is the TypeScript SDK's name for the header. SIGNATURE_HEADER, the 0.1.0 name, still works.

Events: AppointmentWaitingRoomReadyEvent, AppointmentPractitionerJoinedEvent, AppointmentCallEndedEvent, ArtifactReadyEvent, and UnknownWebhookEvent for event names this version doesn't know.

Versioning

The SDK follows semantic versioning. Before 1.0, a minor release (0.2.0) can change the public API; a patch release (0.1.2) fixes bugs and adds features without breaking code written for the same minor version. Every change SDK users can see is in the CHANGELOG and on the GitHub Releases page.

Each release is generated from a pinned copy of the API contract, and its CHANGELOG entry names the contract version. The SDK keeps working when the API adds fields, enum values, stream events, or webhook events (see Models above). New operations need a new SDK release.

Each release supports the Python versions that CI tests, 3.10 to 3.15 today. A Python version is dropped only in a minor release, after it reaches its end of life.

Support

Development

uv sync                                  # Python environment with the pinned dev tools
uv run pytest                            # Tests, against a mock API. No network calls.
uv run ruff check . && uv run ruff format --check .
uv run mypy                              # Strict
uv run python scripts/generate.py        # Regenerate src/doctronic/_generated/

scripts/generate.py regenerates the models (datamodel-code-generator) and the operations table from openapi/doctronic-v1.json, a pinned copy of the public contract at https://docs.doctronic.ai/openapi/doctronic-v1.json. Pass --contract <path|url> to read another copy. The operations table is where the SDK reads which operations are idempotent and user-scoped. Commit the output. CI regenerates from the pinned copy and fails on any difference. regenerate.yml opens a pull request when the docs publish a new contract, and contract-drift.yml reports daily when the pinned copy falls behind. CONTRIBUTING.md has the details.

License

MIT. See LICENSE.

Metadata

Release files for doctronic 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for doctronic 0.1.1
File Size Uploaded Provenance Actions
doctronic-0.1.1.tar.gz 45.9 kB With provenance Details for doctronic-0.1.1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for doctronic 0.1.1
File Interpreter ABI Platform Actions
doctronic-0.1.1-py3-none-any.whl Python 3 none any Details for doctronic-0.1.1-py3-none-any.whl

Total release size: 99.1 kB

Release files / doctronic-0.1.1.tar.gz

Download URL doctronic-0.1.1.tar.gz
Size 45.9 kB
Tags Source
SHA-256 checksum
How to use checksums
ba54fccffd74a44ffafd76dee44e23909088afc31b4a62e9d884f31d710c0ad4
BLAKE2b-256 checksum
How to use checksums
0d675693981208406552005da4782f5f52b9c37b9fc5f37eaf8339ff2292e513
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.

Transparency log

Release files / doctronic-0.1.1-py3-none-any.whl

Download URL doctronic-0.1.1-py3-none-any.whl
Size 53.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3d5d3f5d38a9cb551e649eac4e1f2920678a07ef8fa62a6fad60ef32dd59c6f2
BLAKE2b-256 checksum
How to use checksums
87f4bcac517f71b1018272aa2e041170f67d7c67ec6eb9bb5ff057c4e7db222a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.

Transparency log

Security for doctronic 0.1.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for doctronic-0.1.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page