ask
Public Preview

Application-facing SDK API reference

The Duale AI SDK application API defines task submission, conversation continuation, LibrariesClient, attachment, tool, and configuration interfaces.

Application-facing Python SDK reference covering task submission, continuations, AgentResponse, routing, configuration, decorators, attachments, and LibrariesClient operations.

  • Covers ask, continue_conversation, and AgentResponse methods including model, stream, next, stop, and cache_hit.
  • Documents DualeConfig environment variables, RoutingPolicy fields, ResponseFormat options, and SkillEnum labels.
  • Describes @tool, @activity, and @agent decorators with cache TTL, retry, and JSON-serializability requirements.
  • Details LibrariesClient methods for creating, listing, uploading, and deleting Library documents with tenant_id required.
  • stop_task returns on acceptance; prior usage is billed; only the submitting agent can stop it.

Summaries were generated by AI. Generative AI is experimental.

This page covers the application-facing task, continuation, LibrariesClient, attachment, tool, and configuration APIs. Common convenience imports come from duale; specialized classes use the module paths shown below, and testing helpers live in duale.testing. Bridge wire models remain importable from duale.models.bridge for callers of lower-level SDK methods. Other generated and transport modules are not covered by this application reference unless named below. The distribution is published as dualeai; the import package is duale. Signatures show the application contract; keyword-only arguments follow the * marker.

ask

Submit one task and return an AgentResponse immediately.

async def ask(
    action: str,
    skills: list[SkillEnum] | None = None,
    res: type | None = None,
    response_format: ResponseFormat | None = None,
    routing: RoutingPolicy | None = None,
    *,
    streaming: bool = False,
    deadline: datetime | None = None,
    attachments: list[PreparedAttachment] | None = None,
    request_id: str | None = None,
    sdk: DualeSDK,
) -> AgentResponse
ParameterMeaning
actionThe task instruction. Required, non-empty, and at most 100,000 characters.
resA Pydantic model (or basic type) to validate the result against. Sets the return type of response.model().
routingA RoutingPolicy of model-selection preferences. When present, it replaces the policy that skills would derive.
skillsWhen routing is absent, shorthand for its soft required_skills preference. It also labels the task type for observability.
response_formatA ResponseFormat that constrains the output shape.
streamingWhen True, response.stream() yields content deltas as they arrive.
deadlineA timezone-aware absolute datetime. Defaults to 30 minutes from submission. A naive value currently raises TypeError before submission.
attachmentsPrepared attachment references to include with the task. Upload the files first with sdk.upload_attachments; see Attach documents.
request_idA caller-supplied task identifier: 10–128 ASCII letters, digits, _, or -. Defaults to a generated identifier.
sdkThe DualeSDK instance. Required and keyword-only.

continue_conversation

Submit a non-streaming follow-up linked to an accepted conversation.

async def continue_conversation(
    response: AgentResponse[T],
    message: str,
    *,
    deadline: datetime | None = None,
    response_format: ResponseFormat | None = None,
) -> AgentResponse[T]
ParameterMeaning
responseThe previous AgentResponse. The function waits for it to finish successfully, then uses its task identifier and SDK instance.
messageThe next user message. Required and at most 100,000 characters; it cannot contain NUL. Empty and whitespace-only strings are accepted.
deadlineAn aware absolute datetime. A naive value raises ValueError. Defaults to 30 minutes from submission and cannot extend the current accepted conversation deadline.
response_formatThe output shape for this turn. Omit it to use the normal unstructured response.
  • Parameter
    response
    Meaning
    The previous AgentResponse. The function waits for it to finish successfully, then uses its task identifier and SDK instance.
  • Parameter
    message
    Meaning
    The next user message. Required and at most 100,000 characters; it cannot contain NUL. Empty and whitespace-only strings are accepted.
  • Parameter
    deadline
    Meaning
    An aware absolute datetime. A naive value raises ValueError. Defaults to 30 minutes from submission and cannot extend the current accepted conversation deadline.
  • Parameter
    response_format
    Meaning
    The output shape for this turn. Omit it to use the normal unstructured response.

The SDK generates a child task identifier before it submits the request. The returned AgentResponse.task_id is that child identifier, while response.task_id remains its public parent task identifier. The platform preserves accepted text, tool turns, routing context, and original attachment references. See How the SDK works.

response.next(message, *, res=None, deadline=None, response_format=None) uses the same continuation request after it waits for the response to finish successfully. res= selects the child’s runtime validation model and, when response_format= is absent, derives the child’s JSON Schema. The current type annotation retains the parent’s AgentResponse[T] parameter, so a type checker does not infer a different child type from res=. If the parent fails or the local task is cancelled, .next() propagates that outcome and does not submit a child.

AgentResponse

Returned by ask(), continue_conversation(), and response.next().

MemberBehavior
task_idThe durable task identifier. Available as soon as the call returns.
await model()Await the terminal result, validated against the response model. Raises DualeError on a platform failure, TaskStoppedError when the task was stopped, ValidationError on a schema mismatch.
stream()Async iterator of content deltas and content-reset events. Yields nothing when streaming is False; continuations are always non-streaming.
await next(message, ...)Wait for this task to succeed, then submit a child. res= optionally selects and validates a new result type for that child.
await stop(reason)Stop this task and everything under it. Returns as soon as the platform accepts; does not wait for the task to end.
await cache_hit()Whether the result was served from cache, or None if unknown.
await llm_metrics()The full result-metrics object, or None.
  • Member
    task_id
    Behavior
    The durable task identifier. Available as soon as the call returns.
  • Member
    await model()
    Behavior
    Await the terminal result, validated against the response model. Raises DualeError on a platform failure, TaskStoppedError when the task was stopped, ValidationError on a schema mismatch.
  • Member
    stream()
    Behavior
    Async iterator of content deltas and content-reset events. Yields nothing when streaming is False; continuations are always non-streaming.
  • Member
    await next(message, ...)
    Behavior
    Wait for this task to succeed, then submit a child. res= optionally selects and validates a new result type for that child.
  • Member
    await stop(reason)
    Behavior
    Stop this task and everything under it. Returns as soon as the platform accepts; does not wait for the task to end.
  • Member
    await cache_hit()
    Behavior
    Whether the result was served from cache, or None if unknown.
  • Member
    await llm_metrics()
    Behavior
    The full result-metrics object, or None.

stop_task

Stop a running task and every task started under it.

async def stop_task(self, task_id: str, reason: str) -> TaskStopAccepted
ParameterMeaning
task_idThe task to stop.
reasonWhy it is being stopped. Required, at most 500 characters, and not only spaces. TaskStopAccepted comes from duale.models.task_stop.
  • Parameter
    task_id
    Meaning
    The task to stop.
  • Parameter
    reason
    Meaning
    Why it is being stopped. Required, at most 500 characters, and not only spaces. TaskStopAccepted comes from duale.models.task_stop.

The call returns as soon as the platform accepts the request; TaskStopAccepted carries the task and the moment of acceptance. A task you own and that still runs then ends, and awaiting its model() raises TaskStoppedError with the reason you supplied. stream() yields no stop event: it ends as it ends for any other outcome, so read the outcome from model().

A task that answered before the stop landed keeps its own result, and model() returns it—one task never produces two outcomes. Repeating the same stop is safe and produces the same single stopped result.

from duale import TaskStoppedError

response = await ask("Analyse this contract", sdk=sdk)
await response.stop(reason="Wrong document supplied")

try:
    await response.model()
except TaskStoppedError as stopped:
    print(stopped.reason)

response.stop(reason) is the shortcut for a task you already hold, in the same family as response.next(). sdk.stop_task(task_id, reason) takes the identifier directly, for a task this process did not submit. Only the agent that submitted a task can stop it. A stop for another agent’s task is accepted and then ignored. Confirm a stop by reading the task outcome, not the acceptance.

Stopping does not undo work already sent to a model provider or a tool. It also does not refund usage recorded before the stop. That usage is billed as it is for a task that finishes on its own. Work that the stop prevents never starts, so it costs nothing.

DualeSDK

The client. Use it as an async context manager, or call serve() to publish tools.

class DualeSDK:
    def __init__(
        self,
        config: DualeConfig | None = None,
        agent_id: str | None = None,
        max_jobs: int = 100,
        job_timeout: int = 1800,
        auto_start: bool = True,
        backpressure_config: BackpressureConfig | None = None,
        max_concurrent_tools: int | None = None,
        graceful_shutdown_timeout: float = 0.0,
        transport: HTTPTransportProtocol | None = None,
    ) -> None: ...

    async def serve(self, *, stop_event: asyncio.Event | None = None) -> None: ...

config defaults to a DualeConfig() built from the environment. max_jobs sets the default concurrency for cached activities and registered tools. job_timeout bounds cached activities. max_concurrent_tools overrides only the registered-tool concurrency and otherwise defaults to max_jobs.

On shutdown, graceful_shutdown_timeout allows that many seconds for in-flight tool results. The 0.0 default cancels them immediately. auto_start defaults to True on the constructor. The create_sdk() helper and MockSDK default it to False.

For a task-only client, use async with create_sdk() as sdk:. serve() publishes the registered tools for the configured agent_id and blocks. It runs heartbeats until shutdown. stop_event= provides cooperative shutdown for an embedding application.

Tests can pass transport= to inject an HTTPTransportProtocol. duale.testing provides the simpler keyed-response and in-memory Library helpers.

create_sdk

create_sdk(**kwargs) builds a DualeSDK from keyword arguments, falling back to DUALE_ environment variables, and returns it with auto_start=False. It is the ergonomic constructor for the common environment-configured case.

from duale import create_sdk

sdk = create_sdk(agent_id="agent_security_operations")

Accepted keys: token, endpoint, tenant_id, agent_id, redis_url, sqlite_path, debug, max_jobs, job_timeout, auto_start.

DualeConfig and environment variables

DualeConfig reads DUALE_ environment variables. Nested observability keys use a double underscore.

VariableFieldDefaultNotes
DUALE_TOKENtokenrequired10–256 characters, starts with duale_.
DUALE_ENDPOINTendpointhttps://api.duale.aiMust start with http:// or https://. Use https:// outside local development: Library calls send the API token as a bearer header, so an http:// endpoint puts the token on the wire in clear text.
DUALE_AGENT_IDagent_idnoneRequired for @tool, serve(), and task-scoped attachment uploads. Starts with a letter; letters, digits, hyphen, underscore; 3–50 characters.
DUALE_TENANT_IDtenant_idnoneRequired for Library management and attachment uploads.
DUALE_REDIS_URLredis_urlredis://localhost:8010Optional cache; falls back to SQLite. Must start with redis://.
DUALE_SQLITE_PATHsqlite_pathnoneSQLite cache file path.
DUALE_DEBUGdebugfalseVerbose logging.
DUALE_OBSERVABILITY__ENDPOINTobservability.endpointhttp://localhost:4318OTLP over HTTP base endpoint. A separate outbound destination from DUALE_ENDPOINT: allow it in your egress policy when you export telemetry off the host.
DUALE_OBSERVABILITY__TOKENobservability.tokennoneEnables telemetry export. Use a telemetry token, never your duale_ API token.

RoutingPolicy

Model-selection preferences. Every field is optional and every field is a soft preference. required_skills asks routing to prefer models with evidence for every listed skill, but it still falls back when the full request cannot be satisfied.

FieldRangeOmitted behaviorMeaning
target_accuracy0.0–1.0 or NoneNo emphasis0 or None adds no capability emphasis; higher values favor the highest available capability.
target_permissiveness0.0–1.0 or NoneNo preference0 favors qualified answers, 0.5 balances, and 1 favors direct answers to sensitive but allowed requests. Not a safety control.
cost_sensitivity0.0–1.0 or NoneNo base-tariff preference0 or None adds no base-tariff preference; higher values favor cheaper routes. Cache savings can affect every value. Not a budget.
speed_preference0.0–1.0 or NoneNormal balance0 disables measured-latency preference, None or 0.5 applies the normal balance, and 1 is strongest.
priority_level-10–10Normal balanceAdjusts measured-latency preference. It does not set queue priority.
required_skillslist of SkillEnumEmptyPrefer models with every listed skill; relax to the best available route when unmet.
preferred_skillslist of SkillEnumEmptyImprove preference without excluding other routes.
  • Field
    target_accuracy
    Range
    0.0–1.0 or None
    Omitted behavior
    No emphasis
    Meaning
    0 or None adds no capability emphasis; higher values favor the highest available capability.
  • Field
    target_permissiveness
    Range
    0.0–1.0 or None
    Omitted behavior
    No preference
    Meaning
    0 favors qualified answers, 0.5 balances, and 1 favors direct answers to sensitive but allowed requests. Not a safety control.
  • Field
    cost_sensitivity
    Range
    0.0–1.0 or None
    Omitted behavior
    No base-tariff preference
    Meaning
    0 or None adds no base-tariff preference; higher values favor cheaper routes. Cache savings can affect every value. Not a budget.
  • Field
    speed_preference
    Range
    0.0–1.0 or None
    Omitted behavior
    Normal balance
    Meaning
    0 disables measured-latency preference, None or 0.5 applies the normal balance, and 1 is strongest.
  • Field
    priority_level
    Range
    -10–10
    Omitted behavior
    Normal balance
    Meaning
    Adjusts measured-latency preference. It does not set queue priority.
  • Field
    required_skills
    Range
    list of SkillEnum
    Omitted behavior
    Empty
    Meaning
    Prefer models with every listed skill; relax to the best available route when unmet.
  • Field
    preferred_skills
    Range
    list of SkillEnum
    Omitted behavior
    Empty
    Meaning
    Improve preference without excluding other routes.

An omitted field reads back as None on the RoutingPolicy instance. Explicit None is accepted only for the four nullable numeric fields; omit priority_level and skill lists instead of setting them to None.

ResponseFormat

Either a predefined string—text, json, markdown, html, xml, yaml, or csv—or an object with a json_schema for structured validation and an optional description. Pass a predefined format as its string, for example response_format="json". For a strict schema, construct the object form:

from duale.models.response_format import JsonSchemaResponseFormat

response_format = JsonSchemaResponseFormat(
    json_schema={"type": "object", "properties": {"total": {"type": "number"}}, "required": ["total"]},
    description="Invoice totals",
)

SkillEnum

general, reasoning, analysis, math, code, instruction_following, agentic, long_context, finance, cyber_defensive, cyber_offensive, medical, legal.

A skill label states what a model scored on public, reproducible benchmarks. It does not state a use Duale AI offers or recommends. The prohibited uses listed in the terms of use prevail over any label.

required_skills and preferred_skills are routing weights, not guardrails. They raise the chance of selecting a model that carries the skill; they never forbid a selection. When the pool cannot meet the full skill request, routing uses the best available eligible route.

Decorators: @tool, @activity, and legacy @agent

Use these signatures to declare tools, cached activities, and the legacy agent decorator:

@tool(*, sdk: DualeSDK, description: str, timeout: timedelta,
      retries: int = 0, error_transform: Callable[[Exception], str] | None = None)

@activity(cache_ttl: timedelta | None = None, max_retries: int = 3, *, sdk: DualeSDK)

@agent(name: str, *, sdk: DualeSDK)

@tool registers a function the platform can call. See Authoring tools. @activity caches an async function’s result for cache_ttl, which defaults to one hour. It retries the function up to max_retries extra times.

Its cache key comes from the function name and the repr() of its arguments. The result must be JSON-serializable. @agent records legacy local metadata only. It is not part of the hosted-tool lifecycle manifest.

Tool context fields

Inside a running tool, current_tool_context() returns a ToolContext with tool_call_id, attempt, task_id, and deadline_at (plus remaining_seconds() and is_expiring()), or None outside a tool call. See Authoring tools.

Streaming events

response.stream() yields BridgeContentDeltaResponse (a .delta string) and BridgeContentResetResponse (discard content rendered so far). Take the final result from await response.model(), not the accumulated deltas. See How the SDK works.

Attachments

prepare_attachments() returns PreparedAttachment references. await sdk.upload_attachments(task_id, attachments, agent_id=...) uploads each file to the task’s call-scoped Library and returns dict[str, LibraryDocumentCreateResponse], keyed by PreparedAttachment.key. Pass the configured identity with agent_id=sdk.agent_id. Omit the argument only when the SDK has exactly one agent registered through the legacy @agent decorator. See Attach documents.

LibrariesClient

sdk.libraries manages persistent Library metadata and documents. Every operation requires a configured tenant_id, either through DualeConfig(tenant_id=...) or DUALE_TENANT_ID. The server resolves the caller from DUALE_TOKEN and applies its Library grants.

class LibrariesClient:
    async def create(self, request: LibraryCreateRequest) -> LibraryWithRevision: ...
    async def list(self) -> LibraryListResponse: ...
    async def get(self, request: LibraryGetRequest) -> LibraryWithRevision: ...
    async def update(self, request: LibraryUpdateRequest) -> LibraryWithRevision: ...
    async def delete(self, request: LibraryDeleteRequest) -> None: ...

    async def upload(
        self,
        library_id: str | UUID,
        attachments: list[PreparedAttachment],
    ) -> dict[str, LibraryDocumentCreateResponse]: ...
    async def list_documents(
        self,
        request: LibraryDocumentListRequest,
    ) -> LibraryDocumentPage: ...
    async def get_document(
        self,
        request: LibraryDocumentGetRequest,
    ) -> PublicIndexedDocument: ...
    async def wait_for_document(
        self,
        request: LibraryDocumentGetRequest,
        *,
        timeout: float = 360.0,
    ) -> PublicIndexedDocument: ...
    async def delete_document(
        self,
        request: LibraryDocumentDeleteRequest,
    ) -> None: ...
MethodContract
create(request)Contract: Takes LibraryCreateRequest, which carries only path; the server assigns the Library id. Returns an active Library at the path when the caller can change it. Otherwise creates a new Library and grants the caller access. Paths are non-unique; inaccessible and deleted Libraries do not reserve them. Failure: Returns 409 or 503 when the platform cannot finish creating the Library. A partly created Library does not appear in list(), and the platform refuses reads against it. Recovery: Repeat create(request) with the same path and caller identity. It finishes the Library that the first attempt started instead of creating a second one.
list()Returns LibraryListResponse; its libraries field contains the live Libraries visible to the caller. It fails with BusinessError rather than truncating once the caller can reach more than 5,000 resources.
get(request)Takes LibraryGetRequest. Returns an active Library’s stable id, current path and tags, revision actor and time, and creation time. A deleted Library returns 410.
update(request)Takes LibraryUpdateRequest with a nested LibraryPatchRequest. The patch changes path, replaces the complete tags map, or both. tags={} clears all tags. Changing the path does not change the Library id.
delete(request)Contract: Takes LibraryDeleteRequest. Stops normal reads and writes, makes every document in the Library unrecoverable, and returns the same result for a live, deleted, or unknown Library. The record and grants become eligible for asynchronous removal after 24 hours. Failure: A 503 means the Library is temporarily unavailable for deletion; it stays in place and no data is lost. Recovery: Retry later. Report the failure with the request_id if it persists.
upload(library_id, attachments)Contract: Uploads prepared files concurrently and returns queued receipts by attachment key. The batch is not transactional. Recovery: On LibraryUploadError, inspect completed_receipts. After a platform error, list the documents to reconcile prior successes.
list_documents(request)Takes LibraryDocumentListRequest with required limit, 1 to 500, and nullable cursor. Returns one LibraryDocumentPage. Documents in the Recently deleted window are excluded.
get_document(request)Takes LibraryDocumentGetRequest and reads one document state without polling.
wait_for_document(request)Takes LibraryDocumentGetRequest, polls every five seconds with GET, and returns on ready or failed. The positive timeout bounds the whole polling operation and defaults to 360 seconds.
delete_document(request)Takes LibraryDocumentDeleteRequest and moves one document into the 30-day Recently deleted window. Repeated calls succeed. The core Python client does not expose restore.

The request and response models in the signatures above are importable from duale. Ignore the receipt’s location field, and use its library_id and document_id to build every document request. A failed document is a normal terminal return from wait_for_document; document.failure carries RFC 9457 details, and Errors and reliability lists the causes.

Limits and ceilings

The method table above states the arguments your code passes. Library limits states every other number. These include the file size, the path and tags limits, and the 5,000-resource ceiling on list(). The page also states the upload and deletion windows and the ceilings for the model’s document tools.

Capabilities the client does not wrap

The platform carries five Library capabilities the core client does not wrap. Use the Dashboard for them.

CapabilityWhat it does
GrantsAdd and revoke access to one Library. Needs library:admin on it.
Document previewRead the opening of a document’s extracted text. The only content read-back path.
Document tagsChange a document’s tags after upload. upload() sets none.
Recently deletedList the tenant’s deleted documents.
RestoreReturn a deleted document to its Library. Needs library:write, within 30 days.
  • Capability
    Grants
    What it does
    Add and revoke access to one Library. Needs library:admin on it.
  • Capability
    Document preview
    What it does
    Read the opening of a document’s extracted text. The only content read-back path.
  • Capability
    Document tags
    What it does
    Change a document’s tags after upload. upload() sets none.
  • Capability
    Recently deleted
    What it does
    List the tenant’s deleted documents.
  • Capability
    Restore
    What it does
    Return a deleted document to its Library. Needs library:write, within 30 days.

Revision history has no client. GET {DUALE_ENDPOINT}/libraries/v1/tenants/{tenant_id}/{library_id}/revisions returns one Library’s past paths and tags, newest first, and checks library:read. Call that route directly, because neither the core client nor the Dashboard reads it; get() and update() already return the current revision.

See Manage libraries for the supported workflow and Errors and reliability for DualeAuthError and DualeConnectionError.

Library actions

Five actions decide which Library operations a caller can perform. Use this table to identify the required action and its scope before you request a grant.

The server checks library:upload against the tenant. It checks the other four against the Library the request names, so a subject can hold them on one Library without holding them tenant-wide. The Dashboard shows each action under a plain-language label rather than the identifier below.

ActionScopeOperations that check it
library:uploadTenantcreate(), and starting a document upload
library:readLibraryget(), list_documents(), get_document(), wait_for_document()
library:writeLibrarycreate() when it adopts an existing path, update(), upload(), tag changes, restore
library:deleteLibrarydelete(), delete_document()
library:adminLibraryReading and changing that Library’s grants
  • Action
    library:upload
    Scope
    Tenant
    Operations that check it
    create(), and starting a document upload
  • Action
    library:read
    Scope
    Library
    Operations that check it
    get(), list_documents(), get_document(), wait_for_document()
  • Action
    library:write
    Scope
    Library
    Operations that check it
    create() when it adopts an existing path, update(), upload(), tag changes, restore
  • Action
    library:delete
    Scope
    Library
    Operations that check it
    delete(), delete_document()
  • Action
    library:admin
    Scope
    Library
    Operations that check it
    Reading and changing that Library’s grants

Two operations depart from that table:

  • create() checks twice. First library:upload on the tenant, then library:write on each Library already at the path. It adopts the first that passes and creates a new Library when none does.
  • list() checks nothing per Library. It returns the Libraries the caller can reach with library:read, and raises when that reach exceeds 5,000 Libraries.

Every upload also checks two actions, one at each scope.

Access and isolation states who grants each action, how a refusal behaves, and which boundaries a grant does not draw.

BackpressureConfig

Pass backpressure_config= to DualeSDK to tune cached-activity capacity and the process-local task circuit breaker.

from duale.backpressure import BackpressureConfig

config = BackpressureConfig(
    max_concurrent_activities=100,
    max_pending_activities=500,
    failure_threshold=10,
    recovery_timeout_seconds=30.0,
)

Task submission does not use the activity scheduler. ask() and submit_task() raise RuntimeError only while the task dependency circuit is open.

MockSDK

MockSDK includes keyed task responses and an in-memory sdk.libraries client:

from duale import LibraryCreateRequest
from duale.testing import MockSDK

mock = MockSDK()
mock.set_mock_response("Summarize invoice", {"result": "data"})
result = await mock.mock_ask("Summarize invoice")

library = await mock.libraries.create(LibraryCreateRequest(path="tests/invoices"))

mock.libraries supports the documented core Library workflow in memory and completes queued documents when you call wait_for_document(). mock_ask() is a keyed lookup, not the real ask() path; it does not simulate streaming, tool dispatch, or terminal errors. To drive ask() end to end without a live platform connection, inject a mock transport through the transport= constructor argument.

Versioning and status

The package version is 0.1.0 and the SDK is in Public Preview. A feature page states its own release stage when that stage differs from the rest of the SDK.