Skip to main content

launchables (Python)

Python client for the Launchables Decision API. Standard library only, Python 3.9+.

pip install launchables

Quick start

from launchables import Launchables, noul

client = Launchables()  # reads LAUNCHABLES_API_TOKEN

resp = client.decide(
    "Customer bought the item 12 days ago and has no receipt. "
    "Policy: refunds within 30 days with proof of purchase.",
    {"refund": noul("Is a refund permitted under the policy?")},
    model="myles-g-12b",
)

answer = resp.answers["refund"]
print(answer.probability)                    # probability of yes, e.g. 0.04
print(answer.decide(threshold_yes=0.9, threshold_no=0.1))  # "yes" | "no" | "person"
print(resp.usage.input_tokens, resp.model)

decide(state, questions, model=...) returns a DecisionResponse with .answers, .usage, .model and .raw (the untouched JSON). state may be a string or a dict. To include images, see Images.

Always pass model, a model id from client.models() (for example "myles-g-12b" or "myles-g-31b"), so every request names the model that answers it. The API is moving to requiring it; until then a request without one is answered by the server's default model, which can change.

Question types

from launchables import noul, choice, score

questions = {
    "refund": noul("Is a refund permitted?", true="Policy clearly allows it", false="Policy clearly forbids it"),
    "topic": choice("What is this about?", ["billing", "returns", "other"]),       # or {label: description}
    "urgency": score("How urgent is it?", ["not urgent", "low", "medium", "high"]),  # lowest level first, numbered from 0
}
resp = client.decide(state, questions, model="myles-g-12b")

resp.answers["topic"].choice          # "returns"
resp.answers["topic"].probabilities   # {"billing": 0.06, "returns": 0.89, ...}
resp.answers["urgency"].level         # 2
resp.answers["urgency"].score         # 1.92
resp.answers["urgency"].probabilities # {"0": 0.03, "1": 0.12, ...}

Helpers validate locally and raise ValidationError before any request is sent: at most 16 questions per request, at most 26 options per choice, non-empty instructions.

Other calls

client.models()   # [Model(id="myles-g-12b", ...)]
client.health()   # {"status": "ok"}

models() and health() need no token.

Images

Pass images in assets, keyed by a reference of your choosing, and write that reference wherever the model should see the image: in the state (a text state, or any string value in a JSON state), a question's instructions, or a criterion's description. Option labels are not searched.

from launchables import Image, Launchables, noul

client = Launchables()

resp = client.decide(
    {"receipt": "receipt_photo", "note": "Customer asks for a refund"},
    {"total_matches": noul("Does the total printed on receipt_photo equal 23.40?")},
    assets={"receipt_photo": Image.from_path("receipt.jpg")},
    model="myles-g-12b",
    image_budget=140,  # optional: 70, 140 or 280 (the default)
)

Image.from_path(path) and Image.from_bytes(data) detect PNG or JPEG from the file's first bytes; pass format="png", "jpg" or "jpeg" to state it (it must match the bytes). A plain dict in the wire shape, {"type": "image", "format": "jpeg", "data_base64": "..."}, is accepted as it is. References are matched as plain, case-sensitive text; every place a reference appears shows the image there, and when one reference contains another the longer one wins.

image_budget is the most visual tokens one image may use. Lower is cheaper and faster but sees less detail (small print suffers first). Any value other than 70, 140 or 280 raises ValidationError before sending; leave it out to get the server default of 280.

Limits, checked by the server (each breach is an InvalidRequestError whose message names the asset):

  • PNG or JPEG only, still images, and the bytes must match the format. The helper detects the format from the bytes and refuses anything else before sending.
  • At most 16 MiB per image (the helper checks this too). Width and height each from 280 to 1568 pixels. Images are never resized or re-encoded, by the client or the server, so resize larger images yourself first.
  • At most 8 assets per request, and at most 8 images shown in any one question (each use of a reference counts).
  • Every asset must be referenced at least once. A reference inside a text state must fall within its first 20,000 characters.

Images count as input tokens and are included in usage.input_tokens. At budget 280 one image costs up to about 280 tokens, at 140 up to about 140, at 70 up to about 70. An image referenced in the state is billed once per request however many questions read it; one referenced in a question's instructions or criteria is billed for that question, once per use.

Batches

The Batch API answers many decisions from one submission, within 24 hours, at half the live price per input token. Use it for work that does not need an answer straight away: back-catalogues, nightly reviews, re-scoring after a policy change. Batch support is in version 0.5.0 and later.

from launchables import Launchables, choice, noul

client = Launchables()

batch = client.batch([
    {"custom_id": "ticket-1001", "model": "myles-g-12b", "state": "Charged twice for order 4471",
     "questions": {"refund": noul("Is a refund due?")}},
    {"custom_id": "ticket-1002", "model": "myles-g-31b", "state": {"message": "The app crashes on login"},
     "questions": {"team": choice("Which team?", ["billing", "returns", "technical", "other"])}},
])  # CreatedBatch(id="batch_...", status="queued", lines=2, estimated_cost=..., expires_at=..., data_region=...)

finished = client.batches.wait_for(batch.id)  # polls 5 s, 7.5 s, 11.25 s ... up to 60 s apart

for r in client.batches.stream_results(batch.id):
    if r.ok:
        print(r.custom_id, r.response.answers)  # a DecisionResponse, exactly as decide() returns it
    else:
        print(r.custom_id, r.error.get("type"))  # e.g. "expired", "cancelled", "invalid_question"

Each line is a dict: the body of one decide request with your own custom_id (1 to 64 letters, digits or _ . : -, unique in the batch) and a model, which every line must name: a batch has no default model. Keys are as they are sent: custom_id, model, state, questions, and optionally assets and image_budget. Lines may name different models. Images must be inline (Image.from_path, Image.from_bytes, or a dict with data_base64); a batch line cannot carry file parts.

Call What it does
batch(lines, webhook_url=None) or batches.create(...) Checks every line, sends them as one JSONL file and returns the queued batch (CreatedBatch) with its cost estimate.
batches.list() The calling key's batches (Batch), oldest first.
batches.retrieve(batch_id) Status ("queued", "running", "done", "failed" or "cancelled"), counts (done, failed, pending) and times.
batches.wait_for(batch_id, ...) Polls until the results can be collected. The first check is at once; the wait after it, poll_interval (5 seconds), grows 1.5 times a check up to max_interval (60 seconds), both at least 1 second; timeout (25 hours) raises BatchTimeoutError; setting stop (a threading.Event) raises BatchWaitCancelledError; on_poll(batch) sees each status. It keeps waiting through a brief outage (connection errors, 429 and 5xx other than batch_unavailable). Giving up never cancels the batch.
batches.stream_results(batch_id) Each result line as it arrives, in input order, so a large file is never held in memory.
batches.results(batch_id) Every result line in one list.
batches.cancel(batch_id) Stops the batch. Lines already answered stay in the results and are billed; the rest read as cancelled and are not.

A result is an AnsweredBatchResult (ok is True, response is a DecisionResponse) or a FailedBatchResult (ok is False, error is a dict saying why). Both have custom_id, model (the exact model build that answered), status (the HTTP status the line got, 200 when answered, or None for a line that never ran), usage (input_tokens is what the line is billed on) and raw. BatchTimeoutError and BatchWaitCancelledError carry the last status read in .batch.

The client checks every line before sending and raises ValidationError naming the line (line 3 (custom_id ticket-1003): ...). The server checks every line again and refuses the whole batch if any is invalid: InvalidRequestError with .type "invalid_batch" and each bad line in e.body["errors"] ({"line", "type", "error"}), so nothing is half-queued. Other batch errors: RateLimitError with .type "too_many_batches" when the key already has its limit of batches queued or running (not retried; .retry_after says when to try); ConflictError with .type "batch_not_finished" for results before the batch has ended, or "batch_finished" for cancelling a batch that is already done or failed; NotFoundError ("batch_not_found") for an id the key does not own; APIError with .type "batch_unavailable" where the Batch API is not switched on (not retried).

A submission is retried only after a refusal that stored nothing (429 rate_limited, 503 busy), never after a lost connection or a 502, so a retry cannot submit the same batch twice. Status, results and cancel calls are retried like any other.

Batches belong to the API key that submitted them. A rolled key is a new key: collect a batch's results with the key that submitted it, before that key stops working. See the Batch API documentation for limits, the 24-hour window, webhooks, retention and where batch data is stored.

Key expiry

Every API key expires. The client reads the expiry date from each response, so your program can plan rotation instead of finding out when the key stops working.

def on_expiry(expiry):
    # called once per key and expiry date when 14 days or fewer are left: alert your team, or start a rotation job
    alert_team(f"Launchables key {expiry.key_id} expires on {expiry.expires_on} ({expiry.days_left} days left)")

client = Launchables(on_key_expiry=on_expiry)

client.key()
# KeyInfo(id="c_1a2b3c4d", name="prod", expires_on="2027-01-13", days_left=89, workspace=None,
#         rate_limit_per_minute=600, daily_limit=10000000, paused=False, pause_reason=None)

client.key_expiry  # KeyExpiry(key_id, expires_on, days_left) from the last response, or None

Without on_key_expiry, the client issues one KeyExpiryWarning when 14 days or fewer are left. key() counts as no request and no tokens, and works for a paused key so you can see why it is paused (pause_reason). The expiry headers add nothing to your token count. To replace a key without downtime, roll it on the API keys page of the console: the old key keeps working until the end of the next day.

Managing keys

To rotate keys from your own scripts, use an admin key. An owner makes one on the API keys page of the console (two-step sign-in must be on). Admin keys start with launchables_admin_, always expire, and can only manage your organisation's API keys: they cannot call the Decision API.

client = Launchables(admin_key=os.environ["LAUNCHABLES_ADMIN_KEY"])  # or just set LAUNCHABLES_ADMIN_KEY

page = client.admin.keys.list()  # {"data": [{"id", "name", "prefix", "workspace", "expires_on", "last_used_on", "status", ...}], "has_more", "next_before"}
for key in client.admin.keys.list_all():
    print(key["id"], key["status"])

made = client.admin.keys.create("staging", expires_in_days=90)  # workspace_id is optional
made["key"]  # the full key, shown only this once

rolled = client.admin.keys.roll("c_1a2b3c4d")
rolled["key"]  # the new key; rolled["replaces"]["expires_on"] is when the old one stops (the end of tomorrow)

client.admin.keys.revoke("c_1a2b3c4d")

Key lifetimes are 30, 90, 180 or 365 days (90 by default), never longer than your organisation allows. These calls go to https://console.launchables.ai (admin_base_url to change it) and may be made 120 times a minute per admin key. A missing key or workspace raises NotFoundError (404); the key limit or an archived workspace raises ConflictError (409); both are kinds of InvalidRequestError. create and roll are retried only after a 429, so a retry never makes a second key. Every change shows in Activity in the console.

Error handling

All errors derive from LaunchablesError and carry .status, .message (the server's message), .body and .type (the server's error type, such as "out_of_credit", or None if it sent none).

Error When
AuthenticationError 401, or no key (or admin key) given or found
OutOfCreditError 402: the account has run out of prepaid credit (.type "out_of_credit", top up in the console), or the key's workspace has reached its monthly budget (.type "budget_exceeded")
PermissionDeniedError 403: the key's workspace may not use this model (.type "model_not_allowed"), or the admin key's maker is no longer an owner
RateLimitError 429 (.retry_after seconds, if sent)
InvalidRequestError 400, 411, 413, 422 and other 4xx; ValidationError is the local pre-flight subclass. .type says what was wrong with an image: "invalid_assets", "unsupported_image_format", "invalid_image", "image_too_large", "image_dimensions", "too_many_images", "unreferenced_asset" or "image_reference_truncated" (400), or "invalid_image_budget" (422).
NotFoundError, ConflictError 404 and 409 from the key management API and the Batch API (subclasses of InvalidRequestError)
APIError 5xx
APIConnectionError could not reach the server (including timeouts)
from launchables import OutOfCreditError, RateLimitError, InvalidRequestError, LaunchablesError

try:
    resp = client.decide(state, questions, model="myles-g-12b")
except OutOfCreditError as e:
    print("workspace budget reached" if e.type == "budget_exceeded" else "top up credit")
except RateLimitError as e:
    print("slow down", e.retry_after)
except InvalidRequestError as e:
    print(e.status, e.message)
except LaunchablesError as e:
    print("failed:", e)

Retries

429, 502, 503 and connection errors are retried up to max_retries times (default 3) with exponential backoff of 0.5s, 1s, 2s. A Retry-After header (seconds) is honoured instead, capped at 60s. Other errors, including 402 and 403, are raised immediately. timeout (seconds, default 30) applies to each attempt.

client = Launchables(api_key="launchables_...", timeout=10, max_retries=5)

Self-hosted endpoints

client = Launchables(base_url="https://decisions.internal.example.com")

Requests carry User-Agent: launchables-python/<version>.

Tests

PYTHONPATH=src python -m unittest discover -s tests -t .

Metadata

Release files for launchables 0.5.0

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

Source distribution (sdist)

Source distribution for launchables 0.5.0
File Size Uploaded Provenance
launchables-0.5.0.tar.gz 45.5 kB With provenance Details

Built distribution (wheel)

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

Total release size: 78.6 kB

Release files / launchables-0.5.0.tar.gz

Download URL launchables-0.5.0.tar.gz
Size 45.5 kB
Tags Source
SHA-256 checksum
How to use checksums
9806224e0c79f44956de96dc8b7c979fa6e0680a7d6f2a585629c5fdf301f16a
BLAKE2b-256 checksum
How to use checksums
2d63069976e410c7cbc07943f861ea81a95c74e1dedc3c50bb4b94b9d09ea043
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 20, 2026.

Transparency log

Release files / launchables-0.5.0-py3-none-any.whl

Download URL launchables-0.5.0-py3-none-any.whl
Size 33.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a71fdfe46c9622c95300babdbf89ede62bb9e1e0cca806a64de34215cdd8a922
BLAKE2b-256 checksum
How to use checksums
9aa8f469f98ff43721fc06f14e4099091765aacaa7064371e5ad831388c40272
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 20, 2026.

Transparency log

Security for launchables 0.5.0

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 launchables-0.5.0.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 20, 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.

Release history Release notifications | RSS feed

This release

0.5.0 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