kardinal
Python SDK for the Kardinal route-optimization API: submit a plan, follow its optimization, retrieve the solution, update it to re-optimize.
pip install kardinal-ai # library only
pip install "kardinal-ai[cli]" # + the `kardinal` command
The package is published as kardinal-ai, but you import it as kardinal
(from kardinal import Kardinal) and the command is kardinal. Python 3.10
or later.
The package ships its types, so a type checker sees them: a misspelled attribute or a wrong argument type is reported in your editor, before anything runs.
API documentation: https://developers.kardinal.ai
Quickstart
from kardinal import Kardinal
from kardinal.routing import examples
k = Kardinal() # KARDINAL_API_KEY
plan = k.routing.plans.submit(examples.small_delivery_plan())
solution = k.routing.solutions.wait(plan.id) # returns in ~20s
wait, not get, on purpose: the API is asynchronous. Right after submit,
get raises KardinalNotFoundError (404) while the plan is still waiting
or being processed, then returns whatever the engine has found so far,
which can be empty or incomplete until the optimization is over. wait
returns once it is.
API reference
All methods hang off the client: k = Kardinal().
| Method | HTTP call | What it does |
|---|---|---|
k.routing.plans.submit(plan) |
POST /plans |
Creates a plan from a PlanCreate (no id: the server assigns it) and starts its optimization. Consumes credits. Returns the Plan, with .warnings. |
k.routing.plans.get(plan_id) |
GET /plans/{id} |
Returns the plan as currently stored. |
k.routing.plans.update(plan, force=False) |
PUT /plans/{id} |
Replaces the whole plan (anything left out is removed; fields this SDK version doesn't know, read by get(), are kept and sent back). The version increments, and the plan is re-optimized, only if the plan actually changed; an identical update is a free no-op. force=True unarchives an archived plan first. |
k.routing.plans.delete(plan_id) |
DELETE /plans/{id} |
Permanently deletes the plan and its solution; any optimization in progress is stopped. |
k.routing.plans.list(page=1, items_per_page=20, archived="excluded") |
GET /plans |
Returns one Page of plans, in their light form (PlanLight). |
k.routing.plans.iter_all(archived="excluded") |
GET /plans, every page |
Iterates over every plan. |
k.routing.plans.start(plan_id, force=False) |
PUT /plans/{id}/running |
Restarts the optimization of the current version, without changing its data or version. |
k.routing.plans.stop(plan_id, force=False) |
PUT /plans/{id}/running |
Stops the optimization, without changing the plan's data or version. |
k.routing.plans.get_status(plan_id) |
GET /plans/{id}/state |
Returns the plan's current PlanState: waiting, processing, optimizing, optimized, stopped, interrupted, ... |
k.routing.solutions.wait(plan_id, timeout=None) |
GET /plans/{id}/state, then /solution |
Blocks until the current version stops optimizing, then returns its solution (see below). |
k.routing.solutions.get(plan_id) |
GET /plans/{id}/solution |
Returns the current best solution right away, without waiting. |
k.routing.solutions.get_objectives(plan_id) |
GET /plans/{id}/solution/objectives |
Returns only the value of each objective, without the tours. |
A Page has items, page, total_pages, total, next_page (None
on the last page), previous_page (None on the first page) and
items_per_page. Asking for a page past the last one returns the last page:
.page gives the page actually returned.
How wait() works
- It polls
get_status()every 10 seconds at first, stretching to every 60 seconds on long optimizations, and fetches the solution only once the plan is done. It never sleeps through the wholemaxOptimizationDuration: the engine stops on its own once it stops finding improvements. - The returned solution's
final_statetells how the optimization ended:optimized, orinterrupted/stoppedwhen it was cut short (stopped, replaced by a newer version, or given up by the engine). Check it before treating the result as final. - It raises
KardinalOptimizationErrorwhen it has no solution to return:NO_SOLUTION(the plan ended without one for this version),PLAN_SUPERSEDED(a newer version was pushed meanwhile), orOPTIMIZATION_STALLED(still optimizing 5 minutes past itsmaxOptimizationDuration: the engine most likely crashed). A plan deleted while waiting raisesKardinalNotFoundError. timeout(seconds) is a hard limit that raisesKardinalTimeoutError, after checking the state one last time.- It's meant for scripts and notebooks. A server tracking many plans should
schedule its own
get_status()calls instead of blocking, and like any manual tracking, poll no more than once every 10 seconds per plan.
Configuration
Kardinal(api_key=None, *, base_url=None, timeout=30, max_retries=3)
| argument | env var | default | |
|---|---|---|---|
api_key |
Kardinal(api_key="<your API key>") |
KARDINAL_API_KEY |
(required: raises MissingApiKeyError if none is found) |
base_url |
Kardinal(base_url="https://...") |
KARDINAL_BASE_URL |
https://app.kardinal.ai/api/v2 |
The argument wins over the environment variable, which wins over the
default. api_key="" raises EmptyApiKeyError rather than silently falling
back to the environment variable.
Sandbox and production share the same host: the API key you use determines which of your agencies a call applies to.
timeout (seconds) is passed to httpx as each request's timeout, which
applies to each phase (connecting, sending, each read of the response)
separately, not to the request as a whole.
max_retries is how many times a read, update(), delete(), start() or
stop() is retried after a network error, a 429 or a 5xx; submit() is
never retried (see "Good to know" below).
The client can be used as a context manager (with Kardinal() as k:), or
closed with k.close().
Full walkthrough
from datetime import datetime, timedelta, timezone
from kardinal import Kardinal
from kardinal.routing.plans import PlanCreate, Order
from kardinal.routing import models
from kardinal import KardinalValidationError, KardinalNotFoundError
k = Kardinal(timeout=30, max_retries=3)
# A resource's workingTimeWindow in the past isn't a meaningful plan to
# optimize — computing "tomorrow" here keeps this example runnable as-is,
# rather than pinning a calendar date that goes stale.
tomorrow = (datetime.now(timezone.utc) + timedelta(days=1)).replace(
hour=8, minute=0, second=0, microsecond=0
)
plan_input = PlanCreate(
tz="Europe/Paris",
maxOptimizationDuration="PT5M",
resources=[
models.Resource(
id="truck-1",
vehicleProfile=models.VehicleProfileCar(type="car"),
workingTimeWindow=models.TimeWindow(
begin=tomorrow.isoformat(), end=tomorrow.replace(hour=18).isoformat()
),
)
],
orders=[
Order(
id="order-1",
stops=[
models.SingleStop(
type="single",
id="stop-1",
position=models.Position(lat=48.8566, lon=2.3522),
)
],
)
],
)
try:
plan = k.routing.plans.submit(plan_input) # the server assigns `id`
except KardinalValidationError as e:
print(e.code) # INVALID_VALUE, ID_NOT_UNIQUE, ...
print(e.errors) # every error, not just the first
raise
solution = k.routing.solutions.wait(plan.id) # blocks until it's done
print(solution.final_state) # optimized, or interrupted/stopped if cut short
for tour in solution.tours or []: # tours is None when no stop could be planned
print(tour.resourceId, len(tour.wayPoints))
# manual tracking instead of a blocking wait, what a server tracking many
# plans should do (no more than one call every 10 seconds per plan)
objectives = k.routing.solutions.get_objectives(plan.id) # objective values only
status = k.routing.plans.get_status(plan.id)
# re-optimize: same id; the version increments only if the plan changed
# (an identical update is a free no-op)
plan.orders.append(
Order(id="order-2", stops=[models.SingleStop(type="single", id="stop-2",
position=models.Position(lat=48.86, lon=2.35))])
)
plan = k.routing.plans.update(plan)
A plan has no top-level list of stops: stops live inside Order.stops[],
and Plan.orders[] is the list of orders. An Order groups one or more
stops that must be planned onto the same resource (for example a pickup and
its delivery).
Errors
KardinalError
├── KardinalAuthError NOT_AUTHENTICATED (401): missing, invalid or expired key
├── KardinalLimitError any 403 (NOT_ALLOWED): plan-size limit, optimization quota,
│ or insufficient credits
├── KardinalValidationError the 7 codes under 400, with the full error list
├── KardinalNotFoundError NOT_FOUND: raised on every 404, including reads
├── KardinalRateLimitError 429: temporary overload, retry after a pause
├── KardinalServerError INTERNAL_SERVER_ERROR, or any other 5xx
├── KardinalTimeoutError solutions.wait()'s hard `timeout` elapsed
├── KardinalOptimizationError solutions.wait() has no solution to return: NO_SOLUTION,
│ PLAN_SUPERSEDED, OPTIMIZATION_STALLED (engine crashed)
├── KardinalConnectionError DNS/connection/read failure, no HTTP response at all
└── UnknownFieldError a key no model declares, raised before sending
(submit: any; update: one the server didn't send)
UnknownFieldError is the one raised without a request being made. Most
plan fields are optional, and the API drops a key it doesn't know without
an error: operationDurationn would leave the stop with no service time,
requiredSkills spelled wrong would let any resource serve the order, and
the plan would still come back valid. So submit() lists every such key
with its path and refuses to send:
Unknown field(s) in the plan:
plan.id (not a field you set: the server assigns it)
plan.resources[0].maxWorkingDurationn (did you mean maxWorkingDuration?)
plan.orders[0].stops[0].operationDurationn (did you mean operationDuration?)
update() sorts an unknown key by where it came from. One the server sent
is a field the API added after this SDK version: it is sent back untouched,
since a full replacement would otherwise delete it. One you added is a typo
— the API drops what it doesn't know — and raises, exactly as submit()
does. On a plan this SDK didn't read from the API (built by hand, or loaded
from a file) there is nothing to sort by, so it only warns, and only for a
key within two edits of a real field.
The CLI works the same way: plans update reads the stored plan first to
borrow its provenance, so a typo in an edited file is refused rather than sent.
And --allow-unknown NAME, repeatable, is the command-line form of allow_unknown
on plans submit, plans update and plans validate.
If the API gains a field before this SDK knows it, name it to send it anyway — everything else stays checked:
k.routing.plans.update(plan, allow_unknown=["aFieldAddedByTheApi"])
A name listed there is allowed anywhere in the plan, not only on the
object you had in mind — so prefer a distinctive name over a common one
like priority, which would stop being checked on every resource, order
and stop at once.
To ask before sending rather than catch the error, run the same check yourself:
from kardinal.routing import unknown_fields
for field in unknown_fields(plan):
print(field) # plan.resources[0].maxWorkingDurationn (did you mean maxWorkingDuration?)
Responses are never affected: a key the API adds is kept, not refused.
All of them are importable from kardinal itself
(from kardinal import KardinalNotFoundError), as are MissingApiKeyError
and EmptyApiKeyError (both KardinalErrors, raised by Kardinal()
without a usable key) and Page, the type plans.list() returns.
Every exception carries .code, .status, .request_id, .retryable,
.warnings, .errors and .details. .errors is every error object of
the response; .details is the properties.details of the error .code
came from, the only way to tell apart errors that share a code.
KardinalLimitError is what every 403 becomes: for a builder account, a
403 always means a limit was hit, never a permission refusal. .details
says which one. For a plan-size limit (too many resources, orders or
stops, or more than 4 stops in an order without successiveStops),
.errors also holds one INVALID_VALUE entry per exceeded limit, whose
properties.path and properties.details name the field and the allowed
value: shrink the plan. For the daily/monthly quota, wait or ask for a
higher one; for credits, top them up.
from kardinal import KardinalLimitError
try:
plan = k.routing.plans.submit(plan_input)
except KardinalLimitError as e:
print(e.details) # which limit was hit
print(e.errors) # for a plan-size limit, the field and the allowed value
KardinalRateLimitError (429) signals a temporary overload, not a quota: it
comes with an empty body and, today, no Retry-After header — the SDK
honours one if the API ever sends it, and otherwise backs off on a jittered
schedule of its own. Reads, update(), delete(), start(), stop() and
wait() retry it automatically; submit() doesn't, so retry it yourself
after a pause.
.retryable is computed by the SDK and means "retrying is safe and may
succeed": true for a 429, and for a 5xx or a connection error, except
when a submit() may have reached the server (the plan may then already
exist: check with list() before submitting again). An unrecognized code
falls back to the family matching the HTTP status rather than disappearing,
since the API may add new codes in a non-breaking release. .warnings is
usually empty: it's populated when a rejected payload had already been
amended (a value clamped, crow-fly distances forced, ...) before the error
that rejected it was found.
Warnings
When the API accepts a plan but amends part of it, submit() and
update() return the amendments in .warnings:
plan = k.routing.plans.submit(plan_input)
for warning in plan.warnings:
print(warning.code, warning.properties) # e.g. maxOptimizationDuration clamped
Read them from that return value: warnings aren't stored with the plan, so
a later get() never returns them, only the amended value.
The CLI
kardinal auth check confirms the API key works, and shows its host and agency
kardinal version
kardinal routing plans submit <file> | get <id> | update <file> | delete <id>
kardinal routing plans list | start <id> | stop <id> | status <id>
kardinal routing plans validate <file> local only, no API call, no credits
kardinal routing solutions get <id> | objectives <id> | wait <id>
kardinal routing demo submits the example plan, waits for it
kardinal init writes .kardinal/SKILL.md, a pointer to this SDK for AI coding assistants
JSON output when stdout isn't a terminal, or with --json; a readable table
otherwise. Error output is always JSON, regardless of --json or the
terminal, since it's the one output shape a caller is most likely to parse
programmatically. Exit codes: 0 success, 1 business error, 2 usage
error. The CLI reads the same KARDINAL_API_KEY / KARDINAL_BASE_URL as
Kardinal() (see Configuration above), with no --api-key flag, so a key
never lands in shell history or the process list. solutions wait takes
--timeout <seconds>, and prints how the optimization ended as
finalState, like the API's other fields.
Good to know
submit()is never retried automatically, not even after a network error or a5xx: the API has no idempotency key, so a retry could create the plan twice. Reads,update(),delete(),start()andstop()are retried. Replaying the sameupdate()is harmless: an identical update keeps the same version and isn't billed.maxOptimizationDurationis capped by your agency. A value above the cap is clamped to it, andsubmit()/update()report it in.warnings. By default: 5 seconds in the sandbox (also the default when omitted); in production, 15 minutes when omitted and 6 hours at most. The engine stops earlier on its own once it stops finding improvements.- In the sandbox, distances are crow-fly: road routing is replaced, and
each resource's
vehicleProfilegets a warning saying so. - The SDK identifies itself in two headers on every request:
User-Agent(SDK version, Python version, OS and architecture, and thehttpxversion, plusclifor calls made by thekardinalcommand) andX-Kardinal-Client(SDK version). Nothing personal: no hostname, user or key. .request_idis usuallyNone: it's only set when the server happens to send anX-Request-Idresponse header.get_status()returns aPlanState(a single value), not the detailedPlanStatusobject embedded inPlan.status; a state this SDK version doesn't know yet comes back as its raw string.get_objectives()returns a plainlistofSolutionObjective, with a.warningsattribute attached.start()/stop()return anEnvelopedPlanReference(agencyId,planId,planVersion,warnings): the plan's current version, which starting or stopping doesn't change.
License
MIT.
Metadata
Release files for kardinal-ai 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| kardinal_ai-1.0.0.tar.gz | 106.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| kardinal_ai-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 182.5 kB
Release files / kardinal_ai-1.0.0.tar.gz
| Download URL | kardinal_ai-1.0.0.tar.gz |
|---|---|
| Size | 106.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
530d68aba033518d8b29cc535452f79f7448661fd8f5d37900a43bf20b6fb5f3
|
|
BLAKE2b-256 checksum How to use checksums |
960bee1af1c681e6de22a1fecf47b5b246cbe66d5ff456d2d724dc4ec91baa05
|
| 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 12, 2026.
Transparency logRelease files / kardinal_ai-1.0.0-py3-none-any.whl
| Download URL | kardinal_ai-1.0.0-py3-none-any.whl |
|---|---|
| Size | 76.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
60c5c9ba3c5f7887266842085ef437d16bfd7e7e3cf4769f2a90f9941e2b9f64
|
|
BLAKE2b-256 checksum How to use checksums |
4b31ffe124299a18e48a0e555a01a4da5e231ec8237887011486bb635c9b466f
|
| 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 12, 2026.
Transparency log