Packagenodus-sdk LanguagePython ≥ 3.10 Version1.0.0 APIv1

SDK reference

Every object the Python SDK exposes. New here? Start with the quick start.

# Client

The synchronous entry point. It holds credentials, a base URL, and a pooled HTTP transport, and every other object in the SDK is reached through it.

signature ~/nodus
>>> nodus.Client(api_key=None, *, base_url=None, timeout=30.0, max_retries=2)
ParameterDefaultMeaning
api_key $NODUS_API_KEY Tenant credential. Sent as Authorization: Bearer <api_key>. Missing or unreadable at construction time raises ConfigurationError.
base_url $NODUS_BASE_URL, then https://api.nodus.run Control plane to talk to. Nothing in the SDK is bound to a host, so the same code runs against a control plane you operate.
timeout 30.0 Per-request deadline in seconds. Applies to each attempt, not to the sum of retries. Exceeding it raises APITimeoutError.
max_retries 2 Retries for transient failures only — network errors, 408, 429, and 5xx. Other 4xx responses are returned to you on the first attempt because retrying a rejected payload cannot change the answer.

One client per process is enough. The transport pools connections, so a shared instance amortizes TLS handshakes across every call and a client per request adds one to each submission. The client is thread-safe. The workload handles it returns are not.

MethodReturnsNotes
client.run(**brief) Workload Submit a brief — requirements and outcomes, never a machine. Returns as soon as the workload is accepted; it is not yet placed.
client.get(workload_id) Workload Fetch a fresh handle for an existing workload.
client.list(limit=50, offset=0, status=None) list[Workload] One page, newest first. status takes a WorkloadStatus, its wire string, a list of either, or one of the presets "active" and "terminal".
client.iter_workloads(page_size=50) Iterator[Workload] Pages lazily so you never hold the whole history in memory.
client.cancel(workload_id) None Request a safe stop. Cancellation is a request, not a kill: work in flight is allowed to commit its checkpoint before the status settles on CANCELLED.
client.healthz() dict Unauthenticated liveness probe for the configured base_url.

The client is a context manager: exiting closes the transport and releases pooled sockets. Use it for short-lived processes. Long-lived services should keep a module-level client instead.

client · python ~/nodus
>>> import nodus
>>>
# Explicit configuration; omit any argument to read it from the environment.
>>> client = nodus.Client(
...     api_key="nk_live_…",
...     base_url="https://api.nodus.run",
...     timeout=30.0,
...     max_retries=2,
... )
>>> client.healthz()
{'status': 'ok'}
>>>
# Context-managed form closes the transport on exit.
>>> with nodus.Client() as client:
...     for wl in client.iter_workloads(page_size=100):
...         print(wl.id, wl.status, wl.spend_usd)

# Workload

The handle returned by client.run() and client.get(): one brief's lifecycle state, chosen route, spend, and output.

Handles are mutable. refresh() and wait() update in place and return the same instance, so reads after either call cost nothing. That also makes a handle unsafe to share across threads or tasks. Give each its own from client.get().

AttributeTypeMeaning
idstrStable identifier, wl_….
statusWorkloadStatusCurrent lifecycle state. See Types.
routeRoute | NoneChosen route. None until planning resolves.
spend_usdfloatCommitted spend so far, including recovery already performed.
budget_usdfloatThe ceiling from the brief. Cost to completion is planned against this, not against an hourly rate.
created_atdatetimeAcceptance time, timezone-aware UTC.
updated_atdatetimeLast state transition.
stageslist[StageRun]One entry per stage. A single-stage brief has exactly one.
errorstr | NoneTerminal failure reason; None otherwise.
is_terminalboolTrue once the status can no longer change.
succeededboolTrue only for COMPLETED. Check this rather than is_terminal, which is also true for failure and cancellation.
MethodReturnsNotes
workload.refresh() Workload One read; updates this instance in place.
workload.wait(poll_seconds=2.0, timeout_seconds=None) Workload Poll until is_terminal. Raises APITimeoutError if timeout_seconds elapses first; the workload keeps running, since a client-side deadline is not a cancellation.
workload.events(after=0) list[Event] Ordered lifecycle events. Pass the last sequence you saw as after to read only what is new.
workload.stream_events() Iterator[Event] Blocking iterator that yields events as they occur and stops at the terminal event.
workload.artifacts() list[Artifact] Checkpoint and output manifests, each with name, uri, sha256, bytes, stage_id, and verified.
workload.ledger() Ledger entries (id, entry type, debit, credit, currency, evidence, timestamp) plus a settlement with status and total. This is what a spend number is defensible against.
workload.cancel() None Same safe stop as client.cancel(id).

Each StageRun carries id, status, continuity_mode, completed_units, total_units, and latest_manifest. Progress counts in units the stage defines, so a reclaim that resumes from the last manifest reads as work retained, not work lost.

workload · python ~/nodus
>>> import nodus
>>>
>>> with nodus.Client() as client:
...     wl = client.run(
...         model="7B fine-tune",
...         command=["python", "train.py", "--epochs", "3"],
...         peak_memory_gb=38,
...         expected_runtime_hours=6,
...         budget=180.00,
...         finish_by="2026-07-29T08:00:00Z",
...         continuity="checkpointed",
...         interrupt_tolerance="high",
...         data_regions=["us"],
...     )
...     wl.wait(poll_seconds=5.0, timeout_seconds=8 * 3600)
...
...     # wait() mutated wl in place, so these reads cost nothing.
...     print(wl.status, wl.succeeded)
...     print(wl.route.sku, wl.route.compute_class)
...     print(f"{wl.spend_usd:.2f} of {wl.budget_usd:.2f} budgeted")
...     for art in wl.artifacts():
...         print(art.name, art.sha256[:12], art.verified)
WorkloadStatus.COMPLETED True
nodus:a100-40-us-east accelerator
164.20 of 180.00 budgeted
final.safetensors 9f2c1ab77d40 True

That brief names the work and its constraints: memory high-water mark, runtime, budget, deadline, continuity, residency. It names no machine, and the result reports a catalog route. That is the whole interface. You state what has to be true when the work finishes; Nodus is accountable for making it true.

# Route

The placement decision, expressed entirely in the Nodus catalog. None through ACCEPTED and PLANNING, set from RESERVING onward.

AttributeTypeMeaning
skustrCatalog identifier, always nodus:… — for example nodus:a100-40-us-east.
compute_classstr"accelerator" or "vm".
fit_classstrThe capability class the brief was fitted to, such as a100-40. It describes a capability envelope — memory, interconnect, throughput — not a vendor part number.
regionstrRegion the work runs in, consistent with the data_regions constraint on the brief.
price_usd_hourfloatRate for the route, in USD per hour.
expected_cost_usdfloatCost to completion: the run plus expected recovery.
expected_hoursfloatExpected wall-clock hours of successful execution.
interruptibleboolWhether the route can be reclaimed underneath the workload.

Cost to completion is the only cost that matters. An hourly rate prices an hour, not a finished job. Some fraction of interruptible runs get reclaimed, then re-provisioned and resumed, and that time bills like any other. expected_cost_usd is price_usd_hour × expected_hours plus expected recovery, so it can exceed the naive product of the two. Comparing routes on rate alone picks the wrong one. Your budget is checked against this number, never against a plan that only fits if nothing goes wrong.

The customer surface carries no supplier identity. No supplier field on Route, on Workload, or anywhere in the API. That is the product contract, not a gap in the schema. Nodus picks where the work runs, moves it when capacity is reclaimed, and stays accountable for the deadline and the budget across every move. You get a catalog route and an outcome. The machinery underneath is ours to operate and ours to be wrong about.

# AsyncClient

nodus.AsyncClient takes the same constructor arguments and mirrors every method on Client, awaited instead of blocking: run, get, list, cancel, and healthz are coroutines, iter_workloads is an async iterator, and the client is an async context manager.

signature ~/nodus
>>> nodus.AsyncClient(api_key=None, *, base_url=None, timeout=30.0, max_retries=2)

Calls return an AsyncWorkload: the same attributes as Workload, same types, same in-place mutation, with awaitable methods. await wl.refresh(), await wl.wait(), await wl.events(), await wl.artifacts(), await wl.ledger(), await wl.cancel(), and async for event in wl.stream_events(). Because wait() yields between polls, thousands of concurrent waits cost a task each rather than a thread each.

async · python ~/nodus
# fanout.py
import asyncio
import nodus


async def sweep(client, lr: float) -> nodus.AsyncWorkload:
    wl = await client.run(
        model=f"7B sweep lr={lr}",
        command=["python", "train.py", "--lr", str(lr)],
        peak_memory_gb=38,
        expected_runtime_hours=4,
        budget=120.00,
        continuity="checkpointed",
        interrupt_tolerance="high",
    )
    await wl.wait(poll_seconds=5.0)
    return wl


async def main() -> None:
    async with nodus.AsyncClient() as client:
        runs = await asyncio.gather(
            *(sweep(client, lr) for lr in (1e-5, 3e-5, 1e-4)),
        )
        for wl in runs:
            # route is a Nodus catalog SKU, e.g. nodus:a100-40-us-east
            print(wl.id, wl.status, wl.route.sku, f"${wl.spend_usd:.2f}")


asyncio.run(main())

# Types

Enums compare equal to their wire strings, so wl.status == "running" works without importing anything. Fields taking an enum also accept the string.

ComputeClass — what the work was fitted to.

MemberWire valueMeaning
ComputeClass.ACCELERATOR"accelerator"Anything with a device-memory high-water mark: training, fine-tuning, batch inference.
ComputeClass.VM"vm"Plain CPU and memory: data prep, eval harnesses, ETL, scoring.

Compute class is an output of fitting the brief, not something you declare. A brief that needs no accelerator routes to a VM class through identical planning, pricing, recovery, and settlement. Multi-stage briefs commonly mix both.

ContinuityMode — what must survive an interruption. The most consequential field in a brief: it decides what recovery may do.

MemberWire valueMeaning
ContinuityMode.CHECKPOINTED"checkpointed"Default. State commits to verified manifests as work proceeds. A reclaim resumes from the last one and repeats only the work since.
ContinuityMode.RESTARTABLE"restartable"No intermediate state worth keeping. A reclaim reruns the stage, which beats checkpointing when runs are short.
ContinuityMode.EPHEMERAL"ephemeral"Not worth resuming or repeating. A reclaim ends the stage.

InterruptTolerance — how much interruption the outcome absorbs. Higher widens the feasible routes and lowers cost to completion; lower narrows and raises it.

MemberWire valueMeaning
InterruptTolerance.LOW"low"Prefer routes that are not reclaimed. Deadlines with no slack.
InterruptTolerance.MEDIUM"medium"Interruption acceptable if the deadline holds.
InterruptTolerance.HIGH"high"Interruption routine. With CHECKPOINTED, the lowest cost to completion.

WorkloadStatus — the lifecycle, in order. A workload can bounce between RECOVERING and RUNNING any number of times before settling.

MemberWire valueMeaning
WorkloadStatus.ACCEPTED"accepted"Brief validated and durable. No route yet.
WorkloadStatus.PLANNING"planning"Fitting requirements to a capability class and pricing completion against budget and deadline.
WorkloadStatus.RESERVING"reserving"Holding capacity. route is populated from here on.
WorkloadStatus.PROVISIONING"provisioning"Environment building, inputs staging.
WorkloadStatus.RUNNING"running"Executing. spend_usd and stage progress advance.
WorkloadStatus.RECOVERING"recovering"Capacity reclaimed or environment failed. Nodus is re-placing the work under its continuity mode. Not an error, not your move.
WorkloadStatus.COMPLETED"completed"Terminal. Outcome produced, artifacts verified. The only state where succeeded is true.
WorkloadStatus.FAILED"failed"Terminal. Recovery could not deliver within the brief; error says why.
WorkloadStatus.CANCELLED"cancelled"Terminal. Stopped on request, after in-flight work could commit.

COMPLETED, FAILED, and CANCELLED are the terminal states: the exact set where is_terminal is true and wait() returns. Everything else can still change, so never read RECOVERING as failure.

The SDK also exports the brief models: Requirements, Outcome, Continuity, Policy, StageSpec. Pass keyword arguments to run() and never touch them, or construct them directly when generating briefs programmatically and you want validation before the call.

Event. workload.events() and stream_events() yield these.

AttributeTypeMeaning
seqintMonotonic per workload. Pass the last one you saw as after to resume without re-reading.
idstrStable event identifier. Dedupe on it if you process the same workload from more than one place.
typestrLifecycle transitions and durability milestones — workload.running, checkpoint.committed, workload.completed.
payloaddictEvent-specific detail. Treat unknown keys as additive.
created_atdatetimeWhen the control plane recorded it.

# Errors

Everything the SDK raises inherits nodus.NodusError, so one except nodus.NodusError catches all of it and nothing else. Each carries a request_id where the control plane returned one. Quote it in support requests.

ClassHTTPWhen
NodusErrorBase class for everything below.
ConfigurationErrorBefore any network call: no key resolved, malformed base URL, contradictory arguments. Nothing was sent, so nothing was charged.
AuthenticationError401 / 403Key missing, unknown, revoked, or expired. Never retry.
NotFoundError404No such workload for this tenant. Another tenant's id reads as absent, not forbidden.
ValidationError400 / 422Brief rejected. .code and .message identify the field.
IdempotencyConflictError409Same key, different payload. Change the key, or resend the original to replay it.
RateLimitError429Too many requests. .retry_after gives the seconds.
BudgetExceededError402Would breach a cap on the key. .payload carries the cap, month-to-date, and estimate.
CapacityUnavailableError503No feasible route right now for this combination of requirements, deadline, residency, and budget. Retryable; likelier with a wider brief.
SignatureError401Signature rejected. Usually a stale timestamp, a re-serialized body, or the wrong secret.
APIErrorother 4xx / 5xxAny response with no more specific class.
APIConnectionErrorNever reached the control plane. Retried up to max_retries.
APITimeoutErrorA deadline elapsed: per-request timeout, or timeout_seconds on wait(). The workload keeps running either way.

When writing a handler, the only distinction that matters is whether the condition can change on its own. Rate limits and capacity clear with time. Budget caps clear only if you ask for less. Authentication and validation failures never clear.

errors · python ~/nodus
# submit.py
import time
import nodus


def submit(client, brief: dict, attempts: int = 6) -> nodus.Workload:
    brief = dict(brief)
    for _ in range(attempts):
        try:
            return client.run(**brief)

        except nodus.AuthenticationError:
            # A rejected credential never becomes valid. Fail loudly.
            raise

        except nodus.ValidationError as exc:
            # The brief is wrong, not unlucky. Retrying resends the same brief.
            raise RuntimeError(f"bad brief [{exc.code}] {exc.message}") from exc

        except nodus.RateLimitError as exc:
            time.sleep(exc.retry_after)

        except nodus.BudgetExceededError as exc:
            cap = exc.payload["monthly_spend_cap_usd"]
            spent = exc.payload["month_to_date_usd"]
            needed = exc.payload["estimated_cost_usd"]
            headroom = cap - spent
            if needed > headroom:
                # Nothing fits under this key this month. Escalate.
                raise
            # Resubmit against real headroom rather than the asked-for ceiling.
            brief["budget"] = headroom

        except nodus.CapacityUnavailableError:
            # Retryable. Widen the brief so more routes become feasible:
            # accept interruption, and give up the hard deadline.
            brief["interrupt_tolerance"] = "high"
            brief["continuity"] = "checkpointed"
            brief.pop("finish_by", None)
            time.sleep(30)

    raise RuntimeError("no feasible route after widening the brief")

Widening works because feasibility is a function of the brief, not of capacity alone. Raising interrupt_tolerance and dropping finish_by admits interruptible routes the deadline had excluded, and continuity="checkpointed" bounds the trade: a reclaim costs the work since the last checkpoint, not the run. Relaxing data_regions would widen it further. The handler leaves residency alone because it is a compliance constraint, not a preference.

Pass your own key when you retry across calls. The SDK mints a fresh Idempotency-Key per call to run(), covering that call's internal retries and nothing more. The loop above calls run() again, so it gets a new key and can create a second paid workload if the first submission landed and only the response was lost. Derive a key from the thing you are running, and keep it stable across retries of the same submission:

python ~/your-app
workload = client.run(**brief, idempotency_key=f"nightly-eval-{run_date}")

Change the key whenever the payload changes. Reusing one with a different brief raises IdempotencyConflictError, which is the system telling you two intents were given the same name. Full rule in retries & idempotency.