Python SDK
The dcision package for Python 3.9+ — build from source, decide, destinations and functions, errors, idempotent retries, executions, me() and webhook verification with Flask or FastAPI.
dcision is the official Python SDK. It needs Python 3.9+ and nothing else — only the standard library (urllib, json, hmac) — and it is fully typed (py.typed, mypy --strict clean).
Install
Not on PyPI yet
dcision isn't published on PyPI. Install it from a checkout of the Dcision repository; until this page says it is published, a package with this name on PyPI doesn't come from Dcision.
# From a checkout of the Dcision repository, in your project's environment
pip install ./packages/sdk-pythonuv pip install ./packages/sdk-python works the same way. To ship it elsewhere, build a wheel — python -m build packages/sdk-python writes it to packages/sdk-python/dist — and install that file, or point your requirements at the folder: dcision @ file:///path/to/dcision/packages/sdk-python.
Quickstart
Create an API key in API Keys and set it as DCISION_API_KEY in your server's environment.
from dcision import Dcision
client = Dcision() # reads DCISION_API_KEY
decision = client.decide(
"lead-qualification",
{"message": "We need pricing for 500 users and want to start next month.", "company_size": 500},
)
decision.result["route"] # "sales"
decision.confidence["route"] # 0.85
decision.action # "continue" | "block" | "escalate" | "fallback"
decision.destinations # what happens next: replies, queued deliveries, functions…Configuration
client = Dcision(
api_key=os.environ["DCISION_API_KEY"], # default: the DCISION_API_KEY environment variable
base_url="https://api.dcision.io",
timeout=30,
max_retries=2,
user_agent="acme-crm/2.1",
)| Argument | Default | Description |
|---|---|---|
api_key | DCISION_API_KEY | dcs_live_… or dcs_test_…. A missing key, or one that doesn't start with dcs_, raises DcisionError INVALID_API_KEY at once — nothing is sent. |
base_url | https://api.dcision.io | Must use https — plain http only for an API on a loopback address. A path prefix is fine. |
timeout | 30 | Seconds to wait on each attempt — for the connection and for each read. |
max_retries | 2 | Retries after a network error, a timeout, 429, 502, 503 or 504. |
user_agent | — | Keyword-only. Your app's identifier, prepended to the SDK's: acme-crm/2.1 dcision-sdk-python/0.1.0. |
Create one client and share it — across threads too: it keeps no per-request state. The HTTPS_PROXY environment variable is honored. Invalid arguments raise ValueError or TypeError.
Run a decision
decision = client.decide(slug, state, idempotency_key=None, include_probabilities=False,
functions=None, on_missing_function="raise")state is a str, a dict or a list, as the decision declares. The slug is checked locally (lowercase letters, digits and single dashes) before anything is sent.
| Argument | Description |
|---|---|
idempotency_key | The Idempotency-Key: 1 to 128 characters from A–Z a–z 0–9 . _ : -. Default: a new UUID per call, reused by all its retries. |
include_probabilities | Adds probabilities, the full distribution of every question. |
functions | Handlers for function destinations, by function name. |
on_missing_function | "raise" (default) or "ignore". |
decide() returns a Decision. Every field of the API response is an attribute — decision_id, execution_id, schema (the slug), version, result, confidence, scores, composites, action, action_reason, probabilities, metrics and destinations (always a list: [] when none fired). decision.raw is the response exactly as the API sent it, and decision.function_results lists the handlers that ran.
A decision with onEngineError: "fallback" answers with its fallback action when the engine fails: then decision.action_reason["type"] is "engine_error" and result and confidence are empty.
Destinations
Each entry of decision.destinations is a dict — see the entries:
for destination in decision.destinations:
status = destination.get("status")
if destination["type"] == "reply":
chat.send(destination["text"], destination["buttons"])
elif destination["type"] in ("llm", "agent") and status == "completed":
chat.send(destination.get("text") or destination.get("reply") or "")
elif status == "failed":
log.warning("%s: %s", destination["key"], destination["error"])
elif status == "queued":
log.info("%s queued as %s", destination["key"], destination["delivery_id"])
elif status == "skipped":
log.info("%s skipped: %s", destination["key"], destination["reason"])Function destinations
A function destination names a function and its params. Pass the handlers in functions and the SDK calls them after the decision:
def assign_to_sales(params, decision):
return crm.assign(params["email"], decision.result["route"])
decision = client.decide(
"lead-qualification",
{"message": message, "email": email},
functions={"assignToSales": assign_to_sales, "notifySlack": lambda params, decision: slack.post(params["text"])},
)
decision.function_results
# [FunctionResult(key="assign_owner", function="assignToSales", result=<what the handler returned>)]- Handlers are called as
functions[name](params, decision), in the order ofdestinations, one at a time. Only function entries that fired are called. - Missing handler: every handler is looked up before the first one runs. If one is missing,
decide()raisesDcisionErrorFUNCTION_NOT_REGISTEREDand no handler runs — unless you passon_missing_function="ignore". - A handler raises: the next handlers aren't called and
decide()raisesDcisionErrorFUNCTION_FAILED, withcause(also__cause__),decisionandfunction_results(the handlers that completed). The decision already ran — and was billed. - Handlers run once per
decide()call, after the final answer. Callingdecide()again with the sameidempotency_keyreplays the stored answer and dispatches its functions again, so make handlers idempotent — for example keyed ondecision.execution_id. - Function names are dictionary keys, so any name configured in Dcision works,
$included. - The client is synchronous: an
async defhandler raisesFUNCTION_FAILEDinstead of being skipped silently.
Dispatch later, or in another process, with dispatch():
decision = client.decide("lead-qualification", state) # no functions: nothing is called
queue.push(json.dumps(decision.raw))
# in a worker
from dcision import Decision
results = Decision(json.loads(job.payload)).dispatch(handlers, on_missing_function="ignore")Errors
Everything the SDK raises for an API answer, a network failure, a handler or a signature is a DcisionError:
from dcision import DcisionError
try:
client.decide("lead-qualification", state)
except DcisionError as error:
if error.code == "INVALID_STATE":
report_bad_input(error.message)
else:
raise| Attribute | Description |
|---|---|
code | A stable code: compare it, not message. |
message | A human-readable explanation (also str(error)). |
status | The HTTP status; None when there was no response (network error, timeout) or the check was local. |
request_id | The request's request_id (X-Request-ID): quote it when you contact support. |
details | Extra data for some codes — see Errors. |
retry_after | Seconds from the Retry-After header, when there was one. |
idempotency_key | The Idempotency-Key that decide() sent: reuse it to retry safely later. |
cause | The underlying exception (also __cause__). |
decision, function_results | FUNCTION_NOT_REGISTERED and FUNCTION_FAILED only. |
The API's codes are listed in Errors. The SDK adds NETWORK_ERROR and TIMEOUT (both retried), FUNCTION_NOT_REGISTERED, FUNCTION_FAILED and INVALID_SIGNATURE. Local checks reuse the API's codes with status=None: INVALID_API_KEY for a missing or malformed key, INVALID_REQUEST for a bad slug, idempotency_key or a missing state. An answer that doesn't come from the API — an HTML page from a proxy, a redirect — keeps its HTTP status, with a fallback code (NOT_FOUND, PAYLOAD_TOO_LARGE, INTERNAL_ERROR…). DcisionError can be pickled for multiprocessing and task queues.
Retries and idempotency
decide() sends an Idempotency-Key, generated once per call and reused by every retry, so retries never run, or bill, a decision twice.
| Retried | Not retried |
|---|---|
| Network errors and timeouts | 500 INTERNAL_ERROR |
409 IDEMPOTENCY_CONFLICT with Retry-After: the first request with the key is still running | 409 without Retry-After — a different request reused the key — and the other 4xx |
429 RATE_LIMITED | 502 ENGINE_AUTH_FAILED and ENGINE_INVALID_REQUEST |
Other 502 errors, 503 and 504 | Function and signature errors |
- Up to
max_retriesretries: 2 by default, so 3 attempts. - The SDK waits for
Retry-Afterwhen the API sends it, up to 30 s; a longerRetry-Afterraises at once, withretry_afterset. - Without it, the waits are 0.5 s, 1 s, 2 s… — doubling, at most 8 s — plus up to 25% of random jitter.
me()andexecutionsare retried the same way.
Pass your own key to make retries safe across processes or restarts:
client.decide("support-routing", ticket, idempotency_key=f"ticket-{ticket['id']}")Keep timeout above the decision's own timeoutMs (5 s by default, 30 s at most) plus the timeout of its slowest LLM or sync agent destination, so an attempt has really finished on Dcision's side before it is retried. If it times out anyway, the retry finds the key reserved, waits for Retry-After: 1 and gets the stored answer.
Account and executions
me = client.me()
me["plan"]["rate_limit_per_minute"]
me["usage"]["used"] / me["usage"]["included"]
# one page, newest first
page = client.executions.list(decision="lead-qualification", status="error", limit=100)
page["data"] # the runs: answers, action, metrics — never the inputs
page["next_cursor"] # pass it as cursor= for the next page; None on the last one
# every page
for run in client.executions.iterate(decision="lead-qualification", limit=100):
print(run["created_at"], (run["result"] or {}).get("route"), run["action"])iterate() is a generator: it fetches the next page only when you get to it. Reads are never billed and have their own rate-limit window.
Webhooks
from dcision import DcisionError, verify_webhook
event = verify_webhook(raw_body, signature_header, os.environ["DCISION_WEBHOOK_SECRET"])
event["type"] # "decision.completed"
event["id"] # "dlv_…": the same on every delivery attempt
event["data"]["result"]["route"] # "sales"
event["data"]["params"] # the params mapped in the destination (the state itself is never sent)verify_webhook(payload, header, secret, tolerance=300, now=None):
payload— the raw body:bytes(recommended) orstr;header— theDcision-Signatureheader;secret— the signing secret (whsec_…, in Settings → Destinations), or a list of secrets while you rotate it;tolerance— how far, in seconds, the signature's timestamp may be fromnow;now— Unix time in seconds, for tests.
It raises DcisionError INVALID_SIGNATURE when the header is missing or malformed, no signature matches (compared with hmac.compare_digest), the timestamp is outside the tolerance or the body isn't a JSON event — answer 400. Misuse, such as no secret or a parsed dict as the payload, raises TypeError or ValueError — let it become a 500, so Dcision keeps retrying until the fix is deployed.
verify_signature(...) does the same checks without parsing the body: use it for API requests and agents. Flask and FastAPI receivers are in Webhooks.
Typing
Responses are plain dicts described by TypedDicts in dcision.types — DecisionResponse, Destination and its variants (ReplyDestination, LlmDestination, AgentDestination, FailedDestination, QueuedDestination, SkippedDestination, FunctionDestination…), WebhookEvent, Me, Execution and ExecutionPage:
from dcision.types import WebhookEvent
def handle(event: WebhookEvent) -> None:
route = event["data"]["result"]["route"]Async applications
The client is synchronous. In asyncio code, run it in a thread — verify_webhook() does no I/O and can be called directly:
decision = await asyncio.to_thread(client.decide, "lead-qualification", state)Security notes
- The API key stays private. It is never in
repr()or in an error message, and a value that isn't a Dcision key is refused before any request. - Transport.
httpsonly, and redirects are never followed: urllib would forward theAuthorizationheader to the new host. - Paths. The slug is validated and encoded: nothing else can end up in the URL path.
- Webhooks. HMAC-SHA256 compared in constant time, timestamps outside the tolerance rejected, verification over the raw bytes only.
TypeScript SDK
@dcision/sdk for Node.js, Bun, Deno and edge runtimes — build from source, decide, destinations and functions, errors, idempotent retries, executions, me() and webhook verification.
Use Dcision in Claude Code
Connect Claude Code to Dcision with the MCP server and the Dcision skill — list, check and run decisions, write and validate decision schemas, and use the CLI and SDKs from your agent.