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,
) -> AgentResponsecontinue_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]- 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 raisesValueError. 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().
- 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
DualeErroron a platform failure,TaskStoppedErrorwhen the task was stopped,ValidationErroron a schema mismatch.
- Member
stream()- Behavior
- Async iterator of content deltas and content-reset events. Yields nothing when
streamingisFalse; 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
Noneif 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- 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.
TaskStopAcceptedcomes fromduale.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.
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.
- Field
target_accuracy- Range
- 0.0–1.0 or
None - Omitted behavior
- No emphasis
- Meaning
0orNoneadds 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
0favors qualified answers,0.5balances, and1favors 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
0orNoneadds 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
0disables measured-latency preference,Noneor0.5applies the normal balance, and1is 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: ...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.
- Capability
- Grants
- What it does
- Add and revoke access to one Library. Needs
library:adminon 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.
- 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. Firstlibrary:uploadon the tenant, thenlibrary:writeon 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 withlibrary: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.