Skip to content

Latest commit

 

History

History
359 lines (212 loc) · 136 KB

architecture.md

File metadata and controls

359 lines (212 loc) · 136 KB

pipelex-sdk architecture

The design reference for the package. It covers the shipped surface and records where it stands against the TypeScript @pipelex/sdk reference, gaps included (see "Parity with @pipelex/sdk" at the end).

What this is

pipelex-sdk (import package pipelex_sdk) is the Python client for the Pipelex hosted API. It is the Python counterpart of the TypeScript @pipelex/sdk (PipelexApiClient), built on top of mthds (the mthds-python package) exactly as @pipelex/sdk is built on the mthds npm package.

It is the hosted superset of the MTHDS Protocol:

  • the five normative MTHDS Protocol routes — POST /execute, POST /start, POST /validate, GET /models, GET /version — built on the protocol base (models / version inherited as-is; execute / start / validate lightly overridden for Pipelex-API behaviors documented below);
  • plus the durable run lifecycle (get_run_status, get_run_progress, get_run_result, wait_for_result, start_and_wait);
  • plus the Pipelex product surface (methods catalog, organizations, billing, API keys, onboarding, storage, an organization's files, run records).

The inherited models reads the deck the way the MTHDS Protocol asks of a client reading a model list: an entry of a category mthds defines, judgment among them, reads as its ModelCategory member, and an entry whose category this version does not define keeps its raw string instead of failing the whole deck, since ModelInfo.type is ModelCategory | str | None.

Dependency direction

One-way: pipelex-sdk → mthds. The SDK depends on mthds for the protocol/transport base and never the reverse. This mirrors the TypeScript @pipelex/sdk → mthds edge.

mthds is pinned exactly, and pipelex pins it exactly too, so the two packages move to a new mthds together. Since mthds 0.22.0 the base client reports MTHDS Protocol 1.0.0, whose execute, start and validate bodies carry a method as files, and since mthds 0.24.0 the base takes it that way: its execute and start take files, the standard's map of file entries, and no longer name mthds_contents. This client's execute, start, start_and_wait and validate take files too, or the tree as tree or contents, which they prepare with the base's prepare_files, and no longer name mthds_contents: a caller that must still send the Pipelex API's inline bundles passes them through extra. mthds 0.24.0 also brings the method-tree library (mthds.method_tree) and the blob routes (missing_blobs, upload_blob, prepare_files) a saved method's tree is built on (method-trees.md).

The client is built by inheritance:

mthds.runners.api.client.MthdsAPIClient   (protocol-only base: transport, body-builders, the protocol routes)
    └── pipelex_sdk.client.PipelexAPIClient   (adds lifecycle + product + health on top)

PipelexAPIClient reuses the base transport (_send, _url), the request-body builders, the reusable protocol methods, runner_type, and the async context-manager; it adds the lifecycle, product, and health surfaces plus a richer error/transport layer.

Brand boundary (MTHDS vs Pipelex)

MTHDS is the brand of the open standard (the language, the protocol). Pipelex is the brand of the hosted runtime/product. Artifacts that belong to the standard keep neutral, un-prefixed names; Pipelex branding is reserved for genuinely runtime/product-specific surfaces (the durable run lifecycle, the product routes, implementation envelopes). The five protocol routes and their neutral models stay in mthds; everything Pipelex-specific lives here.

The Pipelex narrowing of the /v1/validate verdict union is one such implementation envelope and lives here (pipelex_sdk.validation_models): PipelexValidationReport / PipelexInvalidReport / the PipelexValidationResult union, plus the supporting ValidatedPipeEntry / DryRunStatus / LiftablePipeEntry / TreeFileRead / TreeFileUse. They stand alone rather than narrowing the neutral ValidationReport / InvalidValidationReport bases that mthds keeps (in mthds.protocol.models), so that each arm stays open to members it does not name; both list their faults as the standard's problem items, ProblemItem from mthds.protocol.problem.models, under a registry code, the invalid arm in errors[] and the valid arm's advisory findings in warnings[], each read member by member (pipelex_sdk.problem_items). The report/union types carry the Pipelex prefix; the supporting types stay neutrally named — branding the envelope, not the fields inside it. The brand-neutral Dict* wire concretes (DictRunResultExecute and friends) stay in mthds — they are a shared wire contract the pipelex runtime itself builds on — and this SDK reuses them by inheritance rather than redefining them (a deliberate divergence from pipelex-sdk-js, which duplicates both the Dict* and the Pipelex* types in its own models.ts).

The same boundary decides several members inside the Pipelex envelope. The input- and output-form descriptors and the pipe I/O contracts are MTHDS artifacts — the standard's own recommended extension fields of the validate report, each with a normative page — so this SDK types them by importing mthds.protocol.input_form, mthds.protocol.output_form and mthds.protocol.pipe_io_contracts rather than declaring them, and does not re-export them under its own name. A Pipelex-branded envelope may carry a neutral artifact; it may not adopt it. See "Typed by import" below.

Credentials & configuration

Resolved at construction time, Pipelex-only — the SDK never consults the mthds resolver (MTHDS_API_KEY / MTHDS_BASE_URL, ~/.mthds/config). That config stores a (base_url, api_key) credential pair for whatever runner the vendor-neutral mthds tooling targets; borrowing the key while ignoring the URL would send a credential configured for another runner to the hosted API (and mthds's base-URL default, a local bare runner on http://localhost:8081, would preempt the hosted default). Both chains match the JS SDK exactly:

  • API key — api_key argument → PIPELEX_API_KEY → anonymous. Presence, not truthiness, decides the argument layer, so an explicit empty string (api_key="") is honored as an anonymous request rather than falling through to a configured env key (mirrors the JS SDK's ?? chain).
  • Base URL — base_url argument → PIPELEX_BASE_URL → the hosted default https://api.pipelex.com. Presence semantics at both layers (also the JS ?? chain): an explicit empty string — a base_url="" argument or a set-but-empty PIPELEX_BASE_URL (e.g. an unfilled CI secret) — reaches the host-only validator and raises RequestArgumentError, its problem client.not_configured, at construction, rather than silently targeting the hosted default with whatever API key is configured.

A token is optional (anonymous access is allowed; protocol routes work against anonymous bare runners, product routes return 401). The base URL is validated host-only (no path/query/fragment/embedded credentials; http/https only), and must be one httpx can make a URL of, so a host holding a control character is refused at construction, as @pipelex/sdk's WHATWG parser refuses it, rather than failing every request; the refusal is a RequestArgumentError whose problem is client.not_configured.

No file is read for either chain: not a .env, and not ~/.pipelex/.env (or the .env under PIPELEX_HOME), where pipelex login and pipelex init save the key as PIPELEX_API_KEY. A program that wants that key loads it into the process environment before constructing the client: from the shell, set -a; . "${PIPELEX_HOME:-$HOME/.pipelex}/.env"; set +a, or, when pipelex is installed, by importing pipelex.system.environment, which loads that file and then the working directory's .env (README, "Configuration").

app_info (constructor argument, an AppInfo) names the integrator in the User-Agent; it is validated when constructed, and the whole header is built and length-checked when the client is constructed (client-identification.md).

request_timeout_seconds (constructor argument, default 1200.0, 20 min) is the time limit of the routes that can take long: the blocking execute, validate, models, and a start carrying the method's tree, a bundle riding extra (mthds_contents or bundle_b64) or a method_ref, which the server resolves before it answers. version and any other start answer fast, so they take request_timeout_seconds capped at the 30-second poll budget, the hosted gateway's own cut-off. The SDK's own polls and product requests keep budgets of their own. The SDK's polls and product GETs use _POLL_REQUEST_TIMEOUT_SECONDS. Each transfer of a method's file to or from storage, an upload before a save, a run, a validation or a crate request, or a download by write_method_tree, takes request_timeout_seconds too, a download bounded whole, its body included.

Conventions

  • Async-only — httpx AsyncClient, async def throughout. No sync facade in v0.1.
  • No barrel — package __init__.py files stay empty; consumers import via full paths (from pipelex_sdk.client import PipelexAPIClient). The public import paths are documented in the README.
  • Wire format — snake_case JSON fields on Pydantic v2 models.

Transport layer

The client inherits mthds's _url ({base}/v1/{endpoint}) and overrides its _send, the one transport seam every route sends through, then layers the product routes' helper on top (pipelex_sdk/client.py):

  • _send — the override of the base's transport seam. It sends through _send_and_read (one raw HTTP request, no status interpretation) and maps a request that got no answer to ApiUnreachableError: an httpx.ReadTimeout or httpx.WriteTimeout, a request that reached the API and outlasted its time limit, gets network_code="ABORT_TIMEOUT", and any other httpx.TransportError (a refused connection, a DNS failure, a TLS failure, a dropped connection, or a connect or pool timeout, neither of which sent the request) gets network_code=<exception class name>, the httpx exception kept as __cause__. Overriding the seam itself, rather than wrapping it in a helper of this client's own, is what reaches the routes the client inherits: the base's execute, start, validate, models and version call self._send, so they meet the same mapping as the run reads, the product routes and health. Non-2xx interpretation stays with the caller.
  • _send_and_read — the request itself, which _send makes here instead of calling the base's _send, for two reasons. The base reads the body inside httpx's request call, so a body that does not decode, such as a broken gzip stream, surfaces as an httpx.DecodingError that has lost the answer's status and headers; and the base takes no per-request headers, so it cannot send the caller's idempotency_key as the Idempotency-Key header of a start or a copy_method. This method builds the base's per-request headers (Accept, User-Agent, Content-Type beside a body), adds the key when one is set in the _IDEMPOTENCY_KEY context variable, which start and copy_method set around their request as _REQUEST_TIMEOUT_OVERRIDE carries a time limit, then streams the answer and reads its body itself. A body that does not decode raises the ApiResponseError that _undecodable_answer builds: the answer's status and headers read as an answer with an empty body, so a 2xx is client.response_unreadable, or client.outcome_unknown on a request that may create a run, as @pipelex/sdk reads it (errors.md), the httpx.DecodingError kept as __cause__. A key that is not 1 to 255 visible ASCII characters is refused with a RequestArgumentError before anything is sent, the blocking execute fallback of start_and_wait sends no key, and the key leaves every verdict as it is (errors.md). Because the headers are rebuilt rather than inherited, tests/unit/test_client_start_idempotency_key.py guards the fork: it holds an unkeyed request to the headers the base's _send sends and a keyed start to those of an unkeyed one plus the key, so a header the base adds later fails a test rather than going missing. Ledger item L-261024-81afa8 asks the base for a transport seam that keeps an undecodable answer and takes per-request headers, which will let this client call super()._send again.
  • _request_product — the product-route path. Serializes the body with pydantic_core.to_json (supporting PUT/PATCH/DELETE as well as GET/POST), and a body holding a lone surrogate, which to_json refuses, as JSON.stringify writes it, the surrogate escaped (_json_body), so the platform refuses the text it cannot store with its own 422, as it does when @pipelex/sdk sends the same body; uses the management-call timeout, maps a non-2xx response to ApiResponseError, and reads a 2xx body with the read its route passes (a model's model_validate, an adapter's validate_python). _request_product_without_answer serves the routes that answer no content (revoke_pipelex_api_key, submit_onboarding, update_run): a 2xx with no body is their answer, and a body they do send must still be JSON.
  • _read_answer — the one place a 2xx body is parsed, for every route but those the client inherits whole, execute, start, models and version. A body that is not UTF-8 or not JSON, an empty one included, and JSON its read refuses with a pydantic ValidationError, raise the ApiResponseError _unreadable_answer builds, the parse failure as its __cause__ (errors.md). Those parse their answer inside the base, so the client runs each in _reading_inherited_answer, a block in which _send records the answer it receives (in a context variable, as the request timeout override is kept): a parse failure met after a 2xx answer is raised again as the same error, while one met before any answer, building the request from the caller's arguments, passes through untouched. A test that replaces _send with a mock records no answer, so it drives these routes through an httpx.MockTransport instead.

start_client is overridden so the Authorization header is sent only when a token is configured — anonymous access (empty token) omits it — and so the spec-conforming User-Agent (built once at construction by pipelex_sdk.user_agent, with the optional app_info in front) is a default header on every request, authenticated or not. See client-identification.md and the spec conformance/specs/client-identification.md, in the conformance repository beside the specs' tests.

Every non-2xx answer becomes an error in one place: _api_response_error(*, method, path, request_url, response), which builds the error, behind _raise_api_response_error(*, method, endpoint, response, problem, problem_from_body), the protected seam of the mthds base that this client overrides. The base's _parse_answer, which every inherited protocol route (execute, start, validate, models, version) reads its answer through, hands a non-2xx to the seam; this client overrides _parse_answer too, so a 2xx it cannot read is reported as every other route reports one (_read_answer). This client's own run status and results reads, its selector validate path and _request_product reach the seam through _raise_v1_refusal, so every /v1 route raises the same ApiResponseError; health, served at the origin, calls the builder with its own path. The builder reads the answer as its problem with read_hosted_answer (pipelex_sdk/answer_reading.py, the twin of @pipelex/sdk's answer-reading.ts): the body as received when it is a problem document, its pipelex.* code and every member the standard does not name kept on problem.model_extra, and otherwise the reading of the edge's bare answer by its status, a request that may create a run reading as client.outcome_unknown (errors.md). The message names the request and the status, then the problem's detail when the body was a problem document, and the body as it came otherwise: API <METHOD> /v1/<endpoint> failed (<status>): <reason>.

Error regimes

Every error the client raises carries its failure as one problem, the standard's Problem, as problem; errors.md gives each class's. The regimes, ported from the TS SDK:

  • An answer holding signed links — a method's listing from get_method or get_method_version, the upload targets of POST /v1/blobs/missing, the file links of link_files and an archive from request_file_archive or get_file_archive — is read with carries_links: an error built from it has every link redacted in response_body (_redact_signed_links, @pipelex/sdk's redactSignedLinks), and is raised with no context and at most a redacted copy of pydantic's refusal as its cause (_raise_with_cause_alone, _validation_error_without_links), since a raise inside an except block keeps the failure it replaces as its __context__ even from None. The base's missing_blobs reads its answer through _parse_answer and _raise_api_response_error, which read that endpoint's answer the same way (_carries_links), and the base refuses an answer asking for a blob the request did not list as an answer the SDK cannot read.
  • Every route — the protocol routes inherited from mthds, the run status and results reads, the product routes and health — raises a typed ApiResponseError on a non-2xx answer. It is mthds's own ApiResponseError refined (class ApiResponseError(PipelexRequestError, mthds.runners.api.exceptions.ApiResponseError)), so a handler written against the standard's client catches it too and reads the same problem. Consumers branch on problem.code, never on the HTTP status or the wording of a message, and read problem.effective_domain for who must act and problem.effective_retry for whether sending the request again can succeed. A hosted refusal's pipelex.* code is one the registry names, and the MethodErrorCode and IdempotencyErrorCode enums name the ones a method caller and a keyed start branch on; the platform may add codes, so an unknown one gets the caller's generic handling by its domain and its retry verdict. The error also carries status, status_text, response_body, headers (lower-case names), request_url, api_url and request_id, the problem's own or else the X-Request-ID header.
  • Transport failures (DNS/connect/TLS/timeout) raise ApiUnreachableError (subclass of PipelexRequestError, itself a PipelineRequestError, and of the standard's ApiTransportError) with api_url, request_url, network_code and its problem on every route — the inherited protocol routes, the run status and results reads, wait_for_result and start_and_wait, the product routes and health — because all of them send through the _send override. A caller that catches PipelineRequestError therefore never meets httpx's own transport exceptions. The standard's client reads a request that got no answer as its own ApiTransportError in _request, after _send, so the inherited routes meet this SDK's mapping first, and this class subclasses the standard's error so that a handler written against the standard's client catches it. Its problem is client.unreachable naming the base URL, client.timeout naming the time limit when the client's own limit ran out (network_code ABORT_TIMEOUT), and client.outcome_unknown for a request that may create a run unless the failure proves the request never left (pipelex_sdk/transport_reading.py, the twin of transport-reading.ts).
  • Arguments refused before sending raise RequestArgumentError, client.argument_invalid: no run source given to execute or start, a protocol or reserved arg in extra, run sources or method selectors that exclude each other, a method's tree given in two forms or holding no file, a selector that is not a string, a suffixed method_id beside the method's tree, a selector rule of validate, an artifact selection that is empty or names an unknown artifact, a suffixed id given to a method route, a publish_method token that is not a string, a get_method_version version that is not a positive integer, an id a route puts in its path (a method's, an example's, a run's, an organization's or an API key's) that is empty, . or .. or is not a str, since the URL would resolve it into another route's path, or that holds a lone surrogate, which a URL cannot carry, a create_method_example whose name or inputs is None or whose run_id is given but empty or not a str, an update_method_example naming neither name nor inputs, a record_method_example_latest_run without a run id or a positive integer input_revision, and a value parse_method_selector refuses. A base URL that is not host-only is one too, client.not_configured, since it typically comes from PIPELEX_BASE_URL. The base client raises refusals of its own (nothing to run, a protocol arg in extra, a request timeout it refuses) as the standard's client.argument_invalid or client.not_configured errors, and its 202 degrade as mthds's RunStillRunningError; the client raises them again as RequestArgumentError and as this SDK's RunStillRunningError, with the standard's message and problem, the standard's error kept as __cause__.

The two translations this client makes on top of the typed errors read them rather than an httpx exception, because the inherited route raises through the overrides before the client's own execute and start see anything: the gateway-timeout check reads exc.status and whether the body is a problem document on an ApiResponseError, and exc.network_code on an ApiUnreachableError, and the missing-route check reads exc.status, exc.response_body and exc.request_url. The error keeps the answer as plain data, never the httpx request, which carries the API key.

A run that may have started. Only a request that may create a run can read as client.outcome_unknown, and the client marks those with a context variable, as _REQUEST_TIMEOUT_OVERRIDE carries the start's time limit: start, execute and the blocking fallback _execute_blocking enter _creating_run() around the inherited call, which sets _MAY_START_RUN for its duration. _send reads it when it builds an ApiUnreachableError, passing the httpx exception as failure, and _api_response_error and _unreadable_answer read it when they build an ApiResponseError, so the problem is decided when the error is built and never changed after it is raised. PipelineExecuteTimeoutError always carries client.outcome_unknown. The run reads start_and_wait makes after the start run outside the context, so a wait error never reads as one. errors.md gives the reading.

execute override (hosted gateway-timeout translation)

The protocol execute is overridden to add one Pipelex-API behavior the bare protocol route doesn't carry, while keeping the inherited error regime for everything else. Behind the hosted gateway, a synchronous request is cut off at ~30s; a blocking execute that exceeds that comes back as a gateway 503/504 (or, less commonly, a client-side request timeout). The override times the call and, when such a failure is observed at or after ~28s elapsed, translates it into a clear PipelineExecuteTimeoutError whose message points the caller at the durable start+poll path. The ~28s threshold guards against mislabeling a fast 503 (the runner genuinely down) as a timeout. This closes a JS-parity gap — the inherited base execute does no such translation (the Phase-1 flag, resolved at Checkpoint 5 by implementing it rather than deferring).

The rule is public, as pipelex_sdk.client.is_gateway_cut_off(exc, elapsed_seconds), the one place the ~28-second threshold is read. @pipelex/sdk's isGatewayCutOff is the same rule. A caller does not time its own start with it to learn whether a failed start may have created a run: every error from a request that may create a run says so in its problem's retry, outcome_unknown, whatever the timing ("A run that may have started" under Error regimes, and errors.md). The check reads the typed errors: the inherited execute raises ApiResponseError through the _raise_api_response_error override, and a client-side read or write timeout arrives as the ApiUnreachableError with the network code ABORT_TIMEOUT the _send override maps it to. The client catches both; a 503/504 whose body is no problem document is read on exc.status and a client-side timeout on exc.network_code == "ABORT_TIMEOUT", and any other unreachable host (a refused connection, a DNS failure) is never read as the gateway's cut-off, however long it took. The translated error keeps the gateway's answer, or the unreachable error, as its __cause__. Everything else passes through unchanged: a client-side timeout before ~28s stays the ApiUnreachableError, the protocol's optional 202 async-degrade still raises RunStillRunningError (from the base execute), and every other non-2xx raises the ApiResponseError itself, a fast 503 included. The _execute_blocking bare-runner fallback (below) calls this same overridden execute, so it inherits the translation; off-platform there is no gateway cap, so the 503/504-after-28s condition effectively never fires there.

The override also enriches the return type: it re-validates the base result into a PipelexExecuteResult (pipelex_sdk/execute_result.py), a DictRunResultExecute subtype that adds a resolved .main_stuff accessor (dug out of pipe_output's working memory via the response's main_stuff_name, raising MissingMainStuffError if unlocatable). This gives blocking and durable results the same output accessor — result.main_stuff — so callers never branch on which path ran. results_from_execute (the public lift from that result onto RunResults, in the same module) reads that accessor too, keeping the resolution single-sourced; the _execute_blocking fallback is its only caller inside the SDK, and a consumer driving execute() directly calls it by name.

Method selectors on the run routes (method_ref + method_id)

The protocol's run args (pipe_code, files, inputs, …) are the base client's named parameters and stay pure. This client's own run args are named parameters here, on execute / start / start_and_wait — one per layer of the stack it fronts, beside tree and contents, the two other forms of the method's tree (see "The method's tree on the runs and validate" below). They are keyword-only everywhere. execute and start keep the protocol client's positional order for the arguments they override, files second, since a caller holding the standard's client may call them positionally; start_and_wait, which the standard does not define, takes every argument but pipe_code by name, so no positional call reads one run source as another. mthds_contents, the Pipelex API's inline bundles, which the protocol no longer names, is no argument of this client either: a caller still sending it passes it through extra, as an extension this client does not know, and the client sends it as given:

  • method_ref (layer 2 — a Pipelex-API extension) is a published method's address, github.com/<owner>/<repo>[/<selector>][@<tag>], resolved by the RUNNER itself (git fetch at the tag, package located by manifest identity; pipelex-api >= 0.21.0). An address is meaningful against a bare runner — git clone needs no catalog — which is exactly why it is a runner argument and not a hosted one. On api.pipelex.com, availability follows the platform deploy that forwards it.
  • method_id (layer 3 — a hosted-platform extension) is a stored method's catalog id (mt_…), resolved by the PLATFORM against the org's catalog; the runner never sees it. It is meaningless off-platform: an open-source runner answers a 422 naming the key.

The reasoning below covers both and generalizes to every extension argument that follows:

  • A named parameter, not an extra entry. Typing its own stack's arguments is the one job this client exists to do; extra stays the escape hatch for an extension this client does not know about (a third-party server, or a newer version of ours). That split, and the reserved-key guard behind it, follow the layered extension policy: the client types its own arguments and guards them per layer, and that guard must never be pushed down into the protocol package.
  • They reach the wire through the base client's extension mechanism. _merge_run_extensions folds them into the extra mapping handed to super().execute / super().start, which merges them into the body as top-level properties without knowing what they mean. That is the layering working as designed, not a workaround: the protocol client stays agnostic while this client owns the concepts. The run's webhooks, callback_urls, are the one named run argument that does not ride extra, since they are no run source (Caller webhooks below).
  • The guard is per layer. method_ref, method_id and callback_urls are rejected inside extra here (a smuggled copy would overwrite the validated named option and bypass the exclusivity checks — extra merges last), and must never become reserved in mthds: a protocol client talking to another vendor's server has no business rejecting that vendor's arguments.
  • method_ref pairs with NOTHING. It is a complete run source — the fetched package carries its .mthds and its entry pipe — so combining it with the method's tree (files, tree or contents) or with method_id raises RequestArgumentError before anything is listed, uploaded or sent, mirroring the server's own 422s (an address run has its own provenance and needs no linkage id). pipe_code beside it is legal: it overrides the manifest's main_pipe. This SDK does not name the bundles that may ride extra (mthds_contents, bundle_b64), so that arm of the server's exclusivity has no client-side twin here.
  • method_id is a pure pass-through — nothing is expanded client-side. The platform resolves the id against the org's catalog. Alone it is a run source; alongside the method's tree the tree is what RUNS (precedence) and the id is recorded as run-history linkage on the Run row — the index key GET /v1/runs?method_id= queries. That linkage exception is the run routes' alone; the tooling routes are strict (below). The base client's "something to run" precondition is satisfied because the merged extension mapping is non-empty, so a selector-only run is sent rather than refused client-side.
  • A method_id may name a version. A bare mt_… runs the method's latest published version, mt_…@<n> the fixed version n and mt_…@draft its draft; the suffix is part of the string and passes through untouched, on the run routes and the tooling routes alike, since the platform parses it at its edge. On the linkage form, beside the method's tree, the id must be bare: the tree is what runs, so a suffix there would claim a version that did not run. The platform refuses it with a 422, and execute and start refuse it first with RequestArgumentError, before anything is listed, uploaded or sent, whichever form the tree takes; a bundle riding extra is left to the platform, as for method_ref. The start ack (PipelexRunResultStart) and the blocking PipelexExecuteResult carry method_version, the version a method_id run runs (a number or "draft"), so a caller learns which version a bare id ran from the answer that started it. Both read it strictly: a boolean, a numeric string or a float is never coerced into a version, and an answer carrying one is an answer the SDK cannot read. pipelex_sdk.method_selector.parse_method_selector splits a selector into its bare id and version locally and refuses a malformed suffix with RequestArgumentError, for a caller that reads the method by its bare id; its ParsedMethodSelector.version is strictly an int, "draft" or None.
  • Provenance comes back typed. A method_ref run's start ack is PipelexRunResultStart (pipelex_sdk.runs) carrying method_provenance: MethodProvenance | None — {address, tag, commit_sha}, the SHA being what keeps the run explainable when a tag moves — and PipelexExecuteResult declares the same field on the blocking path. Both are None for inline-source and method_id runs. The server fetches the package before the 202, so provenance rides the ack; a method_ref start keeps the whole request_timeout_seconds (20 min by default) rather than the capped limit of a plain start, so a cold-cache clone is not cut off, as @pipelex/sdk widens its own start budget for method_ref.
  • Both ride the blocking fallback too. start_and_wait degrades to POST /v1/execute against a runner with no run store, and the selectors go with it — a method_ref run must run the same fetched package there, and a bare pipelex-api answers the 422 that names method_id instead of the client silently dropping it and running something else.
  • An empty string is treated as absent. An empty selector selects nothing and links nothing, so it is not sent and does not satisfy the precondition.
  • A non-string raises at the boundary. A published client validates its request-option types where it names them (_normalized_selector), raising RequestArgumentError rather than dropping or forwarding a wrong-typed value — so one wrong value gets one answer. Without the guard the partition is arbitrary: a bare truthiness check drops the falsy wrong types (0, []) and forwards the truthy ones (123, ["mt_1"]) to a server 422, which is a different partition than @pipelex/sdk makes for the same argument on the same wire. Both SDKs make the same one.

Caller webhooks (callback_urls)

callback_urls is the run routes' second layer-2 parameter, beside method_ref: the webhook URLs a run calls once it has ended, each with a signed run.finished event (the spec conformance/specs/pipelex-caller-webhooks.md). The hosted API and a self-hosted runner both take it, so it rides the blocking fallback of start_and_wait too, and it is reserved on extra with method_ref. _normalized_callback_urls checks only that it is a sequence of strings, text and bytes excepted, and leaves the URLs themselves to the server, whose rules differ between the hosted plane and a runner. It is no run source, so it does not ride extra, whose keys the base client counts towards its "something to run" precondition: _checked_run_extensions hands it on beside extra, execute and start hold it in the _RUN_WEBHOOKS context variable around the one run request the base sends, and the _request override adds it to that request's body as its last member. A run naming its webhooks and nothing to run therefore meets the base's own refusal of a run naming nothing, as in @pipelex/sdk, whose runParts keeps the webhooks in their own part. The receiver's side lives in pipelex_sdk/webhooks.py. See webhooks.md.

The method's tree on the runs and validate

Every call that takes a method takes its tree the way a save does (method-trees.md): files, the protocol's map, sent as given; tree, a MethodTree listed with list_method_tree, or the path of a directory or of a .mthds file, a Path or a str, which the client lists the same way, under the limits the deployment announces, so the standard's membership rule decides what is sent and a run's inputs.json or inputs.toml never is; or contents, each file's bytes or text by tree path. execute, start, start_and_wait and validate prepare a tree or contents through _method_tree_files, as a save and a crate request do, which first refuses a form of the wrong type with RequestArgumentError, then calls the base's prepare_files or prepare_files_from_contents: the tree travels inline when its inline map weighs 256 KiB or less (mthds's DEFAULT_INLINE_TREE_MAX_BYTES), and for a tree no more than the API's limits.method_tree.max_inline_bytes, or when the version handshake declares no blob store, and otherwise every file by hash, once POST /v1/blobs/missing has named the blobs the API lacks and the client has uploaded those, so a blob uploaded once is never uploaded again. A tree costs one handshake per request, which _method_tree_files takes itself, reads both the listing's limits and the inline limit from, and holds in the _TREE_HANDSHAKE context variable while the base prepares the tree, so the version override answers the base's own handshake with it rather than sending a second one. contents takes no handshake of its own, so a small one costs no round trip, as the command-line cases both SDKs run record it.

The order is fixed. The arguments are checked first (_checked_run_extensions: a tree given in two forms, a method_ref beside anything, a suffixed linkage id), so a refused call lists, uploads and sends nothing. The tree is prepared next, outside _creating_run, since neither the blob route nor an upload creates a run, and before the start's own time limit and idempotency key are set. Only then is the run's request sent. An upload that fails raises MethodFileTransferError, whose message ends "so the run was not started" (or "so the request was not sent" for validate) and whose problem never reads as client.outcome_unknown. start_and_wait prepares the tree once, before its version handshake, and hands the files map it made to start and, against a runner with no run store, to the blocking execute, so the fallback uploads nothing again. The runs and validate name no read_scope: on the hosted platform a hash entry resolves in the caller's organization, where the upload put its blob.

Run lifecycle (hosted extension)

The durable run lifecycle (pipelex_sdk/runs.py + the client's lifecycle methods) is a hosted-API extension, not part of the MTHDS Protocol. Long method runs outlive the hosted gateway's ~30s synchronous cap, so a caller submits a run (POST /v1/start), then polls a self-healing endpoint by bare pipeline_run_id until it reaches a terminal state. All state lives behind the id (DynamoDB + Temporal on the platform), so a caller can drop the poll loop and resume later with just the id. A bare runner has no run store and 404s these routes, which the client translates into a clear RunLifecycleUnavailableError.

Owned types (brand boundary)

pipelex_sdk/runs.py owns the lifecycle models — they are a Pipelex-branded surface, mirroring pipelex-sdk-js/src/runs.ts. They are not imported from mthds. During the transition the same shapes still exist in mthds-python; that duplication is deliberate (so this SDK is correct regardless of mthds-python's state) and is removed from mthds-python later. While the base MthdsAPIClient still declares the same lifecycle methods, the client's overrides return this package's own types and so read as incompatible overrides to the type-checker — they carry a narrow # type: ignore[override], which becomes unnecessary (harmless) once the base copies are stripped.

  • RunStatus — the hosted status enum, with is_terminal / is_success predicates (exhaustive match).
  • RunRead — a run record read through the self-healing status path (adds degraded, degradation, request_id and retry_after_seconds). degradation, a RunDegradation read leniently as LenientRunDegradation, says what degraded means: its reason, a DegradationReason read open, tells a status the platform could not refresh (status_unavailable) from a run whose workflow task keeps failing until the platform ends it (workflow_task_failing), so degraded alone does not mean the run is fine (run-results.md). request_id is the id of the request that started the run, to quote to support, and RunDetail carries it too. Its error, declared on RunPublic, is the run's own problem, the standard's Problem read leniently (LenientProblem).
  • The run's own problem — why a run failed, as the platform stored it: the standard's Problem, under its own code (run.cancelled, run.timed_out, a model's or a method's code), with its own domain, retry and next_step. It is read leniently everywhere it is served (LenientProblem, pipelex_sdk/answer_reading.py), so anything but a problem document reads as None and a read never fails on it. One type everywhere — RunPublic.error, PipelineRun.error, RunHistoryItem.error, RunResultFailed.error and RunFailedError.run_error, the last two reading the problem the results read's 409 nests as run_error (errors.md).
  • RunResults — result artifacts, every field walked on run-results.md. main_stuff (the resolved main output content) is always present for a completed run: on the hosted path it is the main_stuff.json S3 artifact; on the bare-runner blocking path the SDK resolves it from the returned working memory via the response's main_stuff_name, so both paths deliver the same shape. Consumers read main_stuff directly. The executed graph (graph_spec, with graph_assembly_error beside it), the three I/O artifacts (pipe_io_contracts, input_form, output_form, typed by import from mthds.protocol exactly as on the validate report, with pipe_io_artifacts_error beside them) and the usage pair read the same on both paths: the hosted path relays each as an artifact, the blocking path lifts each off pipe_output — unwrapping the runner's pipe_io_artifacts envelope onto the three sibling fields. working_memory reads the same way and is typed by import too, as mthds's DictWorkingMemoryAbstract beside the DictPipeOutputAbstract that types pipe_output: the hosted path relays the working_memory.json artifact as its own key, the blocking path lifts pipe_output.working_memory, which the standard declares required and which is therefore always set there. A completed run that cannot deliver a main stuff raises MissingMainStuffError. Extension-open, so any other server artifact is preserved; and a key the hosted body did not carry is absent from model_fields_set, which is how a Python reader tells "not relayed" from "relayed as null" where the JS twin reads undefined.
  • TokensUsageRecord — one client-facing usage record per inference call, carried by RunResults.tokens_usages on both paths. A mirror of the runtime's own record, not a shape this SDK owns — inference accounting is a Pipelex runtime extension the MTHDS Protocol does not model, so the hosted API is what pins that wire contract: every field is optional and the model is extension-open so pre-contract artifacts (relayed verbatim, never migrated) still parse. See run-usage.md for the field reference, the cost/null semantics, and the old-artifact rules.
  • RunProgress — a run's progress snapshot, which get_run_progress reads and WaitForResultOptions.on_progress receives: its status (RunProgressStatus, read open, with is_ended), its version and assembled_at, a one-line summary, the status read's degradation, and every invocation of a pipe in the run as a ProgressInvocation named by its path, with its status (ProgressInvocationStatus, read open), its times, a batch's counts (ProgressBatch), a condition's outcome (ProgressCondition) and the health annotations attempts, last_failure (ProgressFailure, whose problem is the standard's Problem in the caller's view) and degradation, the last reusing RunDegradation read as LenientRunDegradation. chain is a list of ProgressAttempt. The members the freshness rule and a timeline lean on are read strictly and the others leniently (run-results.md). is_fresher_run_progress(read, held) is the pure rule a reader keeps the fresher of two snapshots by.
  • RunResultState — the single-shot result outcome, a union discriminated on state (running / completed / failed).
  • UsageSummary, PipeUsageSummary, UsageTokenTotals and UsageSummaryState (pipelex_sdk/usage.py) — the run-level fold of the usage pair that summarize_usage(results) returns, the twin of @pipelex/sdk's summarizeUsage down to the field names. Owned here because the fold is a reading of a Pipelex runtime extension the standard does not model, exactly as the record it folds is. The function is pure — no I/O, no client, the input untouched — and the null-aware rules it applies are on run-usage.md, including the one divergence from the JS twin: a results body that never carried tokens_usages raises FieldNotIncludedError instead of reading as unavailable, since model_fields_set tells an absent key from a relayed null where TypeScript cannot.
  • WaitForResultOptions / PollInfo — poll-loop tuning, the on_poll callback with its PollInfo, and the on_progress callback, which receives each fresher progress snapshot. Async-native cancellation is via asyncio.CancelledError (cancel the awaiting task), so there is no signal field.

Polling surface

  • get_run_status(run_id) — GET /v1/runs/{id}/status → RunRead. Lifts the Retry-After header onto retry_after_seconds. The stored report and the degradation are each read leniently, so neither ever fails the poll; the wait loops never read this route, since the results route settles a stuck run the same way and raises its end as a RunFailedError.
  • get_run_progress(run_id) — GET /v1/runs/{id}/progress → RunProgress, the run's progress snapshot, read through the run-id guard every run read shares. A route-absent 404 raises RunLifecycleUnavailableError, worded for a bare runner or a hosted API older than the route; the platform's own 404 of a run that does not exist is an ApiResponseError, and so is a snapshot missing a member the freshness rule or a timeline leans on (run-results.md).
  • get_run_result(run_id, *, artifacts=None) — GET /v1/runs/{id}/results, with artifacts (a sequence of RunArtifact) sent as one comma-separated ?artifacts= parameter that narrows the 200 body to the named artifacts; an unselected artifact is absent from the result, which RunResults.carries(artifact) reports, and the MissingMainStuffError check runs only when main_stuff was asked for (run-results.md). It maps the platform's poll semantics to the RunResultState union: 202/503 → running (in-flight / degraded — never fail a poller), 200 → completed, 409 → failed. The 409 is a problem document: run.failed is the failed arm, the document whole as its problem, its detail as message, the terminal status its run_status parameter names as status, and the run's own problem, which it nests as run_error, as error; run.not_finished is the running arm, waiting its retry_after. The status is read from that parameter and never parsed out of the sentence; a run.failed naming no status this SDK knows reads as FAILED. The nested problem is read leniently, so a problem written by another version that does not fit reads as None — message still carries the reason either way. Any other 409 stays an ApiResponseError. The status read and the run lists read the run's problem the same way (LenientProblem).
  • wait_for_result(run_id, options, *, artifacts=None) — polls get_run_result with the same selection to a terminal state, honoring Retry-After and the deadline. Resolves on COMPLETED; raises RunFailedError on any other terminal status, carrying the failed arm's status, message and error, and RunTimeoutError if the budget elapses (the run keeps executing server-side — resume later by id). Each poll is bounded by the budget left with async with asyncio.timeout(remaining), and a poll the budget cuts off is that same RunTimeoutError. asyncio.wait_for is never used there: on Python 3.11 it returns a poll that finishes in the same loop step as a cancellation of the awaiting task, swallowing the cancellation, so the loop polled on until its timeout. asyncio.timeout delivers it on every supported version, so cancelling the task (an asyncio.timeout around the wait, a framework cancelling a request handler, Ctrl-C under asyncio.run) always raises asyncio.CancelledError out of the loop. With on_progress, each poll that finds the run running also reads get_run_progress, after the results read and before on_poll, and delivers each snapshot fresher than the last by is_fresher_run_progress. That read is best effort: one that gets no answer or an answer that is no snapshot skips that poll's snapshot, and a route-absent 404 stops the progress reads for the rest of the wait. It is bounded like the results read: the deadline is checked again before it, asyncio.timeout bounds it by the time left, a read the budget cuts off is the wait's RunTimeoutError, and the elapsed time that clamps the next sleep is taken after it.

These poll GETs send through the _send override like every other route, so a transport failure surfaces as ApiUnreachableError, which ends wait_for_result at once rather than being retried, while a missing-route 404 surfaces as RunLifecycleUnavailableError and any other non-2xx — the platform's run-not-found 404 included — as ApiResponseError.

start_and_wait — hosted ↔ bare self-healing

start_and_wait runs the whole lifecycle in one call and self-heals across runner kinds (the one place this SDK intentionally exceeds mthds-python, whose start_and_wait raises on a bare runner):

  • A cached GET /v1/version handshake (_supports_run_lifecycle) classifies the runner. VersionInfo.implementation == "pipelex-api" ⇒ a bare runner (no run store); anything else ⇒ assumed hosted. The outcome is cached for the client's lifetime. The rule, which @pipelex/sdk follows too, is that an answer that is not a usable version assumes hosted and lets start surface the real error, while no answer at all propagates uncached. An answer is anything the server sent back: an error status, a body that is not JSON, not UTF-8 or no version, and a body that arrived but could not be decoded, such as a broken gzip stream, which _send_and_read reports as the ApiResponseError of that answer read with an empty body (_undecodable_answer), its cause httpx's DecodingError. A handshake that gets no answer raises its ApiUnreachableError at once and caches nothing, so no start is sent and the next call asks again. The handshake and a plain start take request_timeout_seconds capped at the poll budget, and a start carrying the method's tree, a bundle riding extra (mthds_contents or bundle_b64) or a method_ref, which the server resolves before it answers, takes it whole ("Credentials & configuration" above).
  • Hosted: durable start (202 ack) → wait_for_result (poll to terminal).
  • Bare runner: the blocking POST /v1/execute (_execute_blocking), which has no gateway cap off-platform and returns the native pipe_output, mapped onto RunResults — the SDK resolves main_stuff out of the working memory via the response's main_stuff_name and keeps the full working memory on pipe_output.
  • Self-heal: a runner can look hosted yet lack the durable routes (implementation is an optional extension a compliant bare runner may omit). The client's start override catches the ApiResponseError the inherited route raises and translates a bare-runner missing-route 404 into RunLifecycleUnavailableError — raised before any run is created — so start_and_wait falls back to the blocking path without risking a double-run, and caches the negative so later calls skip the durable attempt. A missing route is a 404 whose body is no problem document at all — Starlette's default {"detail": "Not Found"}, or a gateway page. A 404 whose body is a problem document was answered on purpose (a run not found; a method_ref whose package does not exist, which the platform relays from the runner unchanged) and stays an ApiResponseError, so a refused start never demotes the client to the blocking path.

Run/lifecycle errors

RunFailedError, RunTimeoutError, and RunLifecycleUnavailableError are owned in pipelex_sdk/errors.py. RunFailedError carries the run's status, its run_id, run.failed as its problem and, as run_error, the run's own problem the results read nests (or None for a run that ended with none); its message names the run and the reason, so printing it already tells it. wait_for_result, start_and_wait and download_artifacts raise it from the same failed arm. RunStillRunningError — the protocol execute() 202-degrade error — stays owned by mthds and is re-exported from pipelex_sdk/errors.py so consumers have a single import home for all run/lifecycle errors.

validate override (Pipelex-API presentation + the tree + selectors)

The protocol validate is overridden (not inherited) to add the Pipelex-API extensions the bare protocol route doesn't carry. A no-verdict non-2xx surfaces as ApiResponseError on both wire paths, like every other route; the verdict itself is always a 200 discriminated on is_valid:

  • WHAT is validated arrives in exactly one of three forms — the tooling routes' strict three-way XOR. The method's tree (the protocol's files map, given as files, tree or contents as on the runs, its diagnostics naming each file by tree path), method_ref= (runner-resolved by address through the same fetch path as a method_ref run, the package's real file names feeding the diagnostics' source labels), or method_id= (hosted-only, platform-resolved before the runner sees the request). The routes are stateless, so there is no linkage exception: zero selectors or any pairing raises RequestArgumentError client-side, mirroring the server's request-shape 422. The hosted platform validates a tree only for a caller acting in an organization, and answers 400 otherwise. A selector-resolution failure (a fetch failure, no package at the address, an unknown or foreign-org id) is a non-2xx — never an is_valid: false verdict, which is reserved for actual MTHDS content.

  • The two wire paths differ, deliberately. The tree path prepares the tree as a run does (_method_tree_files), then reuses the inherited _post_validate body-building seam, which refuses an empty map and sends render and views through the protocol's extra passthrough; a selector validation must NOT carry the files key at all (the server XORs on presence, and an empty map is a request-shape 422), so its body is built in the override and sent on the same inherited _send transport seam. Both paths get the markdown render injection and both parse into PipelexValidationResultAdapter.

  • Markdown render is always injected. validate(...) adds "markdown" to the render list (de-duplicated, caller tokens first) so both a valid PipelexValidationReport and a produced PipelexInvalidReport carry rendered_markdown. Unknown render tokens are server-side lenient-ignored.

  • views is the opt-in for the server's structured views. The tokens are named by constants — VALIDATION_VIEW_INPUT_FORM for input_form and VALIDATION_VIEW_OUTPUT_FORM for output_form, the descriptor of what the pipe produces. Unlike render, the list travels verbatim: nothing is injected, nothing is de-duplicated, and an explicit [] is sent as [], because the server resolves the tokens as a set and lenient-ignores the ones it does not know (never a 422), so client-side normalization would only hide what the caller asked for. Left at None the key is not sent at all, which is what keeps the default response byte-identical for the consumers that discard views (hook pipelines, CI gates, agent loops). The constant is a constant rather than a closed enum on purpose: the request boundary is deliberately open, so a stale token never fails a call.

The override reuses the inherited base transport seam _post_validate (which builds the body — passing render and views through the protocol's extra extension passthrough — sends the request, and raises on a no-verdict non-2xx), then parses the raw 200 body into this SDK's own PipelexValidationResult via PipelexValidationResultAdapter. Body-building and transport stay shared with the base; only the Pipelex presentation/sources concerns and the branded narrowing live here. The validation models (PipelexValidationResult = PipelexValidationReport | PipelexInvalidReport, with rendered_markdown) are owned by this SDK (pipelex_sdk.validation_models); they narrow mthds's neutral verdict bases, completing the brand boundary (the resolved follow-up #9). The base MthdsAPIClient.validate() returns the neutral ValidationResult instead.

Validate error regime: a no-verdict non-2xx raises ApiResponseError, as the JS validate does. This SDK once kept httpx.HTTPStatusError there so that validate would match the other inherited protocol routes; since mthds raises ApiResponseError from every route and this client overrides the seam it raises through, the same consistency argument now puts every route, validate included, on the typed error. The verdict itself (valid/invalid) is always a 200 either way.

What the report carries. A valid PipelexValidationReport adds typed fields beyond the protocol base. warnings: list[ProblemItem] are advisory findings on a bundle that is nonetheless valid — the same item type as the invalid arm's errors[], so one reader serves both channels, but they never flip is_valid (this is where the hint lints, method.hint_invalid, ride). liftable_pipes: list[LiftablePipeEntry] inventories the pipes the runtime may skip when an optional slot resolves absent. input_form: InputForm | None and output_form: OutputForm | None carry the per-pipe input- and output-form descriptors, keyed exactly like pipe_io_contracts, each present only when the request named that view. default_pipe_ref: str | None is the qualified pipe_ref a selector-less run would execute, None when the closure declares no main_pipe — manifest-aware for a fetched package, which is what makes it outrank a bundle_blueprint read. It is the run default, which names the first declaring domain's main_pipe where several declare one; pipe_io's field of the same name states None there instead, and prepare_inputs reads neither, since the route selects the pipe itself. The lists default empty and the optionals default None, so a body from an older runner still parses; an empty list is also what a clean bundle yields, so no caller can tell the two apart. On the view fields the same default says something stronger: an opt-in view's absence means the request did not ask for it, never that the method has nothing to describe. PipelexInvalidReport gains none of them — they all derive from a crate that was never assembled.

The files each step reads. tree_files_read: dict[str, list[TreeFileRead]] lists, for each step, the files of the method's tree it reads, keyed by namespaced pipe_ref over the same set as pipe_io_contracts. Each TreeFileRead names the file by its tree_path, from the root of the tree with forward slashes and never a path on the server (a file sent in resources is named by its key, collapsed as the runner collapses it), and says in use what the step reads it as: template for a PipeDocGen step's template_file, template_asset for an image, a stylesheet or a font its HTML template names by a literal relative URL, or one a stylesheet it names refers to, and python_module for the module holding a PipeFunc step's function when that module is a file of the tree. A step that reads no file, a controller included, has an empty list. tree_file_readers: dict[str, list[str]] states the same facts the other way, each tree path a step reads, in sorted order, to the refs of the steps that read it, and a file of the tree with no entry is read by no step. The paths come from what each step already names, so a client can show which step uses a file, or warn before one a step reads is renamed or removed, without parsing the method. use is read open, TreeFileUse | str, as the model check reads its categories: a runner of a later version may add a use as another kind of step comes to read files of its tree, and a use this SDK does not know keeps its raw string rather than failing the whole verdict, so a match on it says what an unknown use does. Both maps default empty, so a body from a runner predating them still parses; a pipelex-api runner reports them from the first pipelex release after 0.90.0, and the hosted API once it runs such a release. Unlike the lists, an empty tree_files_read is not what a current runner says of a method with pipes, since it lists every pipe it validated, and "tree_files_read" in report.model_fields_set tells the two apart.

A problem item is the standard's ProblemItem: its code, detail, params, location (the pipe, the file and the line among its members) and, when the runtime plans one, its suggested_fix, the standard's SuggestedFix whose ops are the standard's FixOps. This SDK reads the items member by member (read_problem_items): an item whose code or detail is not a string is skipped, and a member that does not fit is left out of its item, so one malformed member never costs the caller its fault, as @pipelex/sdk reads them (errors.md).

Typed by import: the descriptors and the pipe I/O contracts

Several members of the valid arm are the standard's artifacts rather than Pipelex's, and this SDK narrows them by importing the standard's own client models instead of restating their shape:

  • pipe_io_contracts: PipeIOContracts — dict[pipe_ref, PipeIOContract] from mthds.protocol.pipe_io_contracts. An input slot reads as typed members: concept_ref, a three-valued presence (PresenceMarker, so an authored ! is not flattened into a boolean), a multiplicity (IOMultiplicity), the item_count that is non-null exactly on the fixed arm, and the slot's json_schema. The output side stays asymmetric in exactly one place: a two-valued optional, because ! is a use-site assertion about an input and is rejected on an output. It is no longer asymmetric on the schema — since mthds 0.13.0 an output carries a required json_schema too, and the two answer different questions. An input's describes what a caller sends, so a plural slot's is a bare array; an output's describes what comes back, which is the concept's content model, so a plural output's is that model's list envelope. Both are declared facts, knowable before any run happens.
  • input_form: InputForm | None — dict[pipe_ref, PipeInputFormDescriptor] from mthds.protocol.input_form. A descriptor's fields are the recursive InputFormField union discriminated on kind; narrow a node with match node: case ListField(): … or an isinstance check, importing the per-kind models from mthds.protocol.input_form. An object node recurses through fields, a list node through item — and the item changes layer. Since mthds v0.10.0 the union is split by whether a node names itself: a top-level field is the named union (TextField, DocumentField, …, each requiring name: str), a ListField.item is the nameless one (TextItem, DocumentItem, …), which refuses a name at the parse. Narrow a list's item to DocumentItem, never DocumentField; because each *Field derives from its *Item, the item layer is the only safe narrowing target in that position.
  • output_form: OutputForm | None — dict[pipe_ref, PipeOutputFormDescriptor] from mthds.protocol.output_form, the twin of the above on the other side of the pipe. It carries a single field rather than a fields list, since a pipe has exactly one output, and reuses the same InputFormField node union verbatim rather than declaring a second one that could drift — so a renderer walks it with the patterns it already has. It states no presence and no gating: those are facts of a slot a caller fills, and a result is not one; plurality is stated by wrapping the field in a list node. Read it together with that pipe's output.json_schema off pipe_io_contracts — the descriptor says what the result IS, the schema names the property its payload arrives under, and a consumer holding one but not the other is back to inferring the other from the value.

Why import rather than declare. These artifacts belong to MTHDS: they describe what a method takes and what it produces, which is a language-level fact, and any engine derives them from a resolved library with no Pipelex API in the loop. They were carried opaquely until now for a reason that has since expired — when that call was made, no published Python package declared them, so "type it here" could only mean "copy it here", and a copy is free to drift from the runtime that emits it. Since mthds 0.9.0 the standard's own client declares them, so typing them means importing them: one declaration per language, nothing to drift from. The principle the opaque ruling was protecting — this SDK is transport and does not own these types — is what an import preserves and a restatement would have broken.

The types are imported and used, never re-exported from pipelex_sdk. Re-exporting would put this package's name on a vocabulary it does not own and hand consumers a second import path to drift against; import them from mthds.protocol directly.

The same three artifacts ride a completed run's results too — RunResults.pipe_io_contracts, input_form and output_form, built over the library the run executed against and relayed from the three sibling files the worker writes beside graphspec.json — and they are typed there by the same imports, under the same ruling and with the same strictness: a member the pinned mthds does not define fails the parse of the whole results body, main output included, exactly as it fails the validate report. That is a choice, made once for both surfaces rather than relaxed for one: the artifacts are written by the same pipelex the pin tracks, every run older than the artifacts carries them as None, and reading a drifted artifact half-way is worse than refusing it. See run-results.md.

bundle_blueprint and graph_spec stay opaque, for exactly the reason that used to cover all four: no published package declares them, so a type here could only be a copy. When either gets a standard page and a client model, it moves the same way.

Strictness composes; it does not spread. The imported artifacts are closed shapes (extra="forbid"): a member this mthds version does not define is version drift and fails the parse, where catching it is cheap. The report envelope around them stays extension-open — PipelexValidationReport inherits extra="allow" from mthds's ValidationReport, and declaring typed fields on a subclass does not touch that config — so an unrelated field a future server adds to the report still parses and still rides model_extra, exactly as before. That is the standard's own arrangement: the report is the envelope and grows, the artifact is the view of one version and does not. A test pins both halves, so a later edit cannot quietly close the envelope.

The one break. A valid report whose contracts predate the presence/multiplicity reshape — an input carrying the boolean optional instead of presence, or missing multiplicity / item_count — no longer parses, where it used to ride through untyped. The hosted plane emits the reshaped contracts, so this bites only against runners older than that reshape, and there is no compatibility shim by design: an artifact that does not conform to the standard version this package pins is version drift, and saying so at the parse is the point.

Crate routes (resolve / codegen / pipe_io) and the closure selector

resolve, codegen and pipe_io (pipelex_sdk/crate_models.py + the three client methods) are the crate routes, mirroring the JS SDK's: POST /v1/resolve emits the normalized library crate (the MTHDS Library Crate Format — typed as opaque transport, dict[str, Any], because the crate schema is owned by the standard and a restatement here would be free to drift), POST /v1/codegen projects that crate through the kind × target axes into stamped typed artifacts plus their codegen.lock (write both verbatim and the offline pipelex codegen check passes on the tree), and POST /v1/pipe-io returns a method's three I/O artifacts without validating it. All three follow the same 200-verdict discipline as validate, sharing CrateInvalidReport as their invalid arm, and ride _request_product, so a no-verdict condition raises the typed ApiResponseError.

pipe_io reads a method's inputs and outputs in one call, with no dry run. The closure resolves through the same static core as resolve, one pipe is selected, and PipeIOValidReport carries that pipe's pipe_io_contracts, input_form and output_form — typed by import from mthds.protocol, under the same ruling and strictness as the validate report's members of those names (see "Typed by import" above) — beside the resolved qualified pipe_ref, the method's own default_pipe_ref, pending_signatures and is_runnable. PipeIORequest adds pipe_ref (sent as given), all_pipes (key the three maps by every pipe the closure loads, and never refuse for want of an entry pipe) and include_files (echo the text of the method's own .mthds bundles on the valid arm as files, a map from tree path to text, None otherwise; the tree's other files and its dependencies' bundles are never echoed). The server selects the pipe: the request's pipe_ref, else the main_pipe of the METHODS.toml at the tree's root, sent or fetched, else the closure's single main_pipe declaration; an unknown ref, or no ref and a chain that finds none or several, is a 422 the route raises as ApiResponseError. Two fields are easy to misread. is_valid: true means the closure passed static validation, never that it runs, since only validate dry-runs. And default_pipe_ref is the route's own chain, a stated None where that chain refuses to choose, which is not validate's run default of the same name. prepare_inputs reads its signature from this route; see input-preparation.md.

Their closure selector is the strict three-way XOR — the method's tree, an address-form method_ref (server-resolved; the registry form keeps its 501), or the hosted method_id pass-through — enforced at request construction (CrateToolingRequest._exactly_one_selector) as well as by the server. The JS SDK leaves that check to the server for its crate routes; the wire is the same either way. The shared envelope itself (CrateRequestBase, CrateInvalidReport) lives in crate_models.py beside the routes that use it; it sat in a build_models.py module until prepare_inputs moved onto the input-form descriptor and the /v1/build/inputs wrapper was removed with it.

The tree is the MTHDS protocol's files map, the shape validate, the runs and the method catalog take: each key a tree path, each value a FileEntry from mthds.method_tree.file_entry, the file's text, a Base64FileEntry or a HashFileEntry naming a blob the organization already holds. The runner writes the tree whole and loads it as the method's library directory, so every .mthds bundle is named by its tree path, which a problem item's location.file and the crate's source_map carry, and every other file, a PipeDocGen template for instance, is one a step may read. A request gives the tree in exactly one of three forms, as a save does (method-trees.md): files, the map, sent as given; tree, a MethodTree listed with list_method_tree or the path of a directory or of a .mthds file, which the client lists the same way when it sends the request; or contents, each file's bytes or text by tree path. The model refuses two forms at once. The client sends a tree or contents through the same preparation a save uses (_method_tree_files): inline when its map weighs 256 KiB or less, and for a tree no more than the inline limit the API announces, otherwise, against an API whose version handshake declares a blob store, by hash after POST /v1/blobs/missing and an upload of each blob the organization lacks. A blob that fails to upload raises MethodFileTransferError, whose message ends "so the request was not sent", and the route is never asked; a tree holding no file is refused with RequestArgumentError before any request, rather than left to mthds's bare PipelineRequestError. An empty map is absent, as in the JS SDK: files={}, contents={} and dependencies={} select nothing and are not sent, while files=None is Python's own absent default. Beside the tree, dependencies carries the method packages it calls by address, each a files map keyed by its address, which the runner loads beside the method so a cross-package call resolves; a call into a package nothing shipped is the invalid verdict, and dependencies beside a method_ref or a method_id is refused at construction. read_scope is the Pipelex-API extension naming the scope a hash entry resolves under: a caller of a bare runner may send it, and the hosted platform stamps it from the caller's organization and drops one the caller sent. A request carrying one asks the blob route under it too, as POST /v1/blobs/missing?read_scope=…, so the blobs land in the store the request's hash entries are read from: the base client asks that route with no scope of its own, so _method_tree_files sets the scope in the _TREE_READ_SCOPE context variable while the base prepares the tree, and the _request override sends the base's blobs/missing request at the scoped endpoint, the base reading the answer under the endpoint it named. Each upload target the runner answers is signed for that scope, so the uploads need nothing more, and the hosted platform's own blob route reads the caller's organization and ignores the query.

The two SDKs' crate bodies differ by one member. The JS SDK's crate requests also take resources, the files a step reads, as a base64 map beside the tree; this SDK sends a step's files in the tree itself, as the protocol does, since the tree carries any file. So that a caller porting a JS request learns this at once rather than from a closure that does not load, the crate request models refuse a member they do not declare (CrateRequestBase is extra="forbid"): resources is a pydantic ValidationError at construction, never a member dropped from the wire.

A method_ref closure gets a fetch-sized budget. Resolving an address can make the server clone a repository before it answers, and the server-side clone timeout runs well past the 30s management budget on a cold cache — an abort there would report a healthy, still-cloning server as unreachable. So a method_ref-carrying resolve / codegen / pipe_io uses an internal 3-minute budget (_METHOD_REF_FETCH_TIMEOUT_SECONDS, threaded through _request_product's request_timeout override); it is internal (no new caller-facing parameter) and inert behind the hosted gateway's own cap, which answers a request past 30 seconds with a 502 that a retry clears once the runner has cached the clone. Mirrors the JS SDK's fetch budget; the run routes and validate need none because they already ride the 20-min blocking ceiling.

Model reference check (check_model_reference)

check_model_reference(reference, *, category=None) (pipelex_sdk/model_reference_models.py + the client method) asks the runner whether one model reference, written as a method's model field writes it, resolves, as what kind and to which model: GET /v1/models/check, a Pipelex API extension served by any pipelex-api runner from pipelex 0.78.0 and on the hosted API, whose platform relays it byte-identically. The runner answers from the parser and the deck lookups a validation runs, so this SDK keeps no parser of references of its own and checks nothing before sending: the reference and the optional type travel percent-encoded in the query, and the call rides _request_product with the management-call budget, as @pipelex/sdk's checkModelReference rides the poll budget. It is a diagnostic route like validate: a reference that resolves nowhere is a 200 verdict whose resolution is NOT_FOUND, with suggestions, other_kinds and other_categories saying what the caller may have meant, never a raised error. Only a request the runner cannot produce a verdict for raises ApiResponseError, a 422 request.invalid whose problem's location.field says which: reference for a reference that is blank, a sigil or a namespace alone, or past the runner's length limit, type for an unknown category, and the parameter's name for a missing or repeated one.

The verdict is parsed on its own kind. A matches entry carries no kind of its own, and PresetMatch is AliasMatch plus description, so validating each entry against a union of the four shapes would have to guess, and an extension-open model would let a preset entry pass for an alias. The wire already says which shape every entry has: all the matches of one verdict share the verdict's kind. So ModelReferenceVerdict is a union of one arm per kind, PresetReferenceVerdict, AliasReferenceVerdict, WaterfallReferenceVerdict and HandleReferenceVerdict, discriminated on kind (Field(discriminator="kind"), parsed through ModelReferenceVerdictAdapter), and each arm types its matches as that kind's entry. The parse never guesses, no field is added to the wire to make it so, and a consumer narrows the verdict, and its matches with it, with match verdict: case PresetReferenceVerdict(): … or isinstance. ModelReferenceMatch stays as a plain alias of the four entries for typing a consumer's own code, and the models are extension-open (extra="allow") like the package's other response models. @pipelex/sdk types the verdict with the same arms under the same names.

ModelCheckCategory is the protocol's model categories plus doc_gen, the family of PipeDocGen, which the protocol, and so the inherited models, leave out but a method's model field can name. A request names a ModelCheckCategory member, and ModelReferenceKind is closed, since it is the discriminator. A verdict reads the rest open, each as ModelReferenceResolution | str or ModelCheckCategory | str validated left to right as mthds reads ModelInfo.type: a value this SDK knows reads as its member, and one it does not keeps its raw string rather than failing the verdict. For resolution that is the spec's reader rule, that a client reading a value it does not know treats the reference as unresolved; for the verdict's category, its other_categories and a match's category, it is a category a runner of a later version adds. A match's resolves_to is None when the reference resolves by name but a run through it would reach no model the runner can call, which a consumer shows as a warning rather than as an unknown name.

Codegen tree writer (write_codegen_tree)

codegen() returns a projection; write_codegen_tree(report, output_dir=…) (pipelex_sdk/codegen_writer.py) puts it on disk, so a Python project regenerates its typed tree with nothing installed but this SDK. It owns bytes to disk and nothing else: which methods to generate, which directory each tree goes into, and anything a project writes beside the tree stay project policy, with the caller.

Verbatim is the contract. Every artifacts[] entry is written at its path and the lock as lock_filename, as UTF-8 bytes with no newline translation, no reformatting and no re-serialized lock. That fidelity is what makes the tree identical to a local pipelex codegen types run, and therefore what lets the offline check pass on it; a writer that "tidied" an artifact would produce a tree the check reports as hand-edited. The discipline is pipelex's own write_stamped_projection (pipelex/codegen/emission.py), applied to a projection the server already stamped, and a tree written here was diffed byte for byte against that function's output over a generation and a pruning regeneration:

  • Validate before the first byte. A lock_filename other than codegen.lock, an unsafe or duplicate artifact path, a lock this SDK cannot read or that does not track exactly the response's artifacts, a symbolic link or a regular file on the way to a destination, a destination that is not a regular file, or a previously tracked path about to be pruned that sits behind a symbolic link or is no longer a regular file raises CodegenError and writes nothing. The lock and prune checks go further than pipelex: its writer builds the lock from the files it writes, so the two cannot disagree, and it resolves prune targets only after writing. Here the lock arrives as a separate field, and it is what the next run trusts for ownership and pruning. A previously tracked path whose directory has since become a regular file is skipped, as pipelex skips it: no file can be there to prune, and refusing it would block every rerun, because the refusal also keeps the new lock from replacing the old one.
  • Never overwrite a file codegen does not own. An existing file at an artifact's path is replaced only when it is already identical, when the previous lock tracked it, or when it opens with a stamp. Opening with a stamp means the begin line here, where pipelex also requires the rest of the header to parse, so a file whose stamp header was edited is overwritten by this writer and refused by pipelex.
  • Write only what changed, so regenerating over a current tree touches no mtime.
  • Prune what dropped out. A file the previous lock tracked, absent from the new set and still stamped, is deleted; a file whose stamp was removed by hand, or that no lock ever tracked, is left for the offline check to report.
  • Write the lock last. A previous lock that cannot be read (malformed, not UTF-8, an unknown lock_version) is replaced and prunes nothing; a previous lock tracking an unsafe path is refused, because that is a containment violation rather than corrupt state.

The format primitives sit in two modules of their own, mirroring pipelex's layout so the offline check can build on them: pipelex_sdk/codegen_lock.py (the lock model and parser, the version gate, the artifact path rules and the symlink-refusing output resolution) and pipelex_sdk/codegen_stamp.py (the stampable suffixes, their comment syntax, the ownership predicate, and the stamp parser and content hash the check needs). Nothing in either builds a stamp or encodes a lock; the server does both.

Synchronous on purpose. It is local file I/O with no network leg, so it is a plain function called after await client.codegen(...), like pipelex's writer, rather than an async def that would only wrap blocking calls.

Offline codegen drift check (run_codegen_check)

run_codegen_check(root=…) (pipelex_sdk/codegen_check.py) is the writer's counterpart and the reason a Python consumer of the hosted API needs no pipelex install at all: pure hashing over a generated tree and its codegen.lock, so it boots no engine, opens no socket and reads no API key. Before it existed the only offline gate was the pipelex CLI — the whole runtime, which is precisely the dependency a hosted-API consumer took this SDK to avoid, and pipelex-starter-python had been pointing a PIPELEX ?= variable at an install it does not depend on for exactly that reason. @pipelex/sdk has carried the TypeScript half since v0.12.0; this closes the asymmetry.

What it proves, and what it cannot. It proves the tree and its lock still agree: nothing edited, nothing missing, nothing lingering. Whether the tree still matches what the method resolves to is a second question that needs the engine, so the check never asks it — the report surfaces crate_fingerprint and engine_version from the lock header so a caller can close that gap against a live codegen() response. The split is the point: regeneration is a developer action because it needs the engine, the check is the CI action because it needs only hashes, so an upstream template improvement never reddens a consumer's pipeline.

Four drift categories, one per artifact at most. A locked artifact absent on disk is missing; one whose body is off the locked hash while its stamp still agrees is modified; one whose stamp is gone, unparseable or self-inconsistent is hand-edited; a stamped file the lock does not track is an orphan — the stale-artifact class a per-file stamp cannot catch alone, and the whole reason a lock sits beside the stamps. hand-edited outranks modified, so a hand edit trips both conditions and is reported once. The order is contractual: locked drifts first in ascending path order, then orphans in that same order. A missing lock is lock_found: False rather than a drift — there is nothing to check — and is_current still refuses to vouch for such a tree. A lock that cannot be read, or a tree whose paths are not safe and canonical, raises CodegenLockError: the absence of a verdict, never a drift.

A mirror of pipelex.codegen.check.run_codegen_check, verbatim down to the drift sentences, because the codegen spec pins the algorithm as pure hashing precisely so every client reaches the same verdict over the same bytes; a consumer moving between pipelex codegen check and this function must read the same report. Artifacts are read in text mode, so universal-newline translation folds a CRLF away before anything is hashed — not a convenience but what keeps the two in agreement, since pipelex's reader does the same and a Windows-generated tree would otherwise read as entirely hand-edited here and current there. The orphan scan prunes the same vendor, VCS and cache directories and skips symbolic links for the same reason.

Two deliberate divergences, one in each direction. The relaxation is inherited from the TypeScript port: parse_stamped requires the projection line to be present and well-formed but does not match its kind / target against this SDK's vocabulary. pipelex validates them against its own enums, which cannot lag its own emitter; an SDK copy can — CodegenKind here is a single-member Literal — and rejecting an unknown-but-valid future axis would report every artifact of a correctly generated tree as hand-edited. For today's vocabulary the two readers are identical on that axis.

The tightening is this reader's own: a Python artifact declaring a PEP 263 source encoding is refused as hand-edited, where pipelex and @pipelex/sdk both call it current. Such a declaration makes the comment-prefix gate unsound, because CPython chooses the file's codec from it before tokenizing, so under an escape-decoding codec a header value can become a line break followed by an executable statement — in the one region of the file no hash covers. It is the same hole the splitlines rule closes for U+2028, reached through another door, and closing it rejects nothing a real generated tree contains because the emitter never writes a coding: line. Diverging by catching a tampered tree the reference passes is the safe direction for a CI gate; the gap in the other two implementations is filed on the ledger.

Beyond those two the verdicts match, including the drift sentences. One difference is not a verdict at all: where the reference lets a PermissionError out of an unreadable file or directory, this one raises CodegenLockError, so a CI caller has a single class to catch. Neither produces a verdict for that state.

What the committed fixture proves, and what it does not. tests/unit/data/real_codegen_tree/ holds a genuine pipelex codegen types tree — engine 0.57.0 over the cookbook's documents method, byte for byte, the artifact suffixed .txt only so this repo's linters leave it alone — and the suite re-proves on every run that this implementation reads real engine bytes as current, flips to hand-edited with the exact sentence when a line is appended, and is unmoved by a CRLF checkout. Its hashes are self-proving: recompute SHA-256 over the body below the fence and it equals both the stamp's recorded value and the lock's. What the suite cannot re-prove is agreement with pipelex, because this package must not depend on it — that is the point of the module. Cross-implementation parity was established once, by an out-of-tree harness that ran both implementations as subprocesses in separate virtualenvs over scenario trees derived from real engine output, one drift class at a time. Re-proving it per run needs a home that may depend on both, which is the workspace's conformance/ suite rather than this repo.

Pipelex product surface (hosted management routes)

The hosted catalog/account routes the webapp drives (pipelex_sdk/product_models.py + the client's product methods). Every route rides the same {base}/v1/* surface, Authorization: Bearer, org-from-JWT contract as the protocol routes, and goes through _request_product, which maps a non-2xx problem document to a typed ApiResponseError — consumers branch on .problem.code, never the HTTP status (see Error regimes above).

The wire models are snake_case Pydantic v2. Response models are extension-open (extra="allow") so a newly-added server field is preserved, not rejected; input models name exactly what each route accepts. RunHistoryItem.status and PipelineRun.status reuse the run-lifecycle RunStatus; OrgRole and the onboarding fields are StrEnums. Membership.role_in_org reads its OrgRole open, as RunDegradation.reason and the model check's categories do: a role this SDK knows (admin, member, runner) reads as its member, and one a later platform adds keeps its raw string rather than failing the whole memberships response.

  • User profile — get_me() → UserProfile (GET /v1/me).

  • Methods catalog — list_methods() / iterate_methods() / get_method(id) / create_method(MethodWriteInput) / write_draft(id, MethodDraftInput) / rename_method(id, name) / publish_method(id, expected_draft_updated_at=…) / list_method_versions(id, limit=…, cursor=…) / get_method_version(id, version) / delete_method(id). The id is bare and path-encoded; an absent input_data is dropped from the create body.

    A saved method has a draft and immutable published versions. Its draft is a method tree with its input_data, which write_draft (PUT /v1/methods/{id}/draft) replaces without validating it: the whole tree, given as files, tree or contents, sent inline or by hash after uploading the files the organization lacks (method-trees.md). get_method answers a MethodDetail, the method with files, its draft's tree listed with a short-lived link to each file's bytes, which write_method_tree brings to disk; the creation, the draft write, the rename and the publish answer a MethodData, which lists no files. MethodData.updated_at is the draft's token, which moves on every draft write and nothing else, and draft_digest is the draft's tree digest, sha256: and 64 hex digits, or None for an empty draft. The draft body always carries files, and carries input_data only when the caller set it, which is what gives it its keep, clear and replace meanings, and it may carry expected_updated_at, the token the caller last saw: the write is then a compare-and-swap, and a draft that moved since is a 409 pipelex.catalog.update_conflict with nothing written. rename_method (PATCH /v1/methods/{id}) changes the name alone and moves no token. publish_method (POST /v1/methods/{id}/publish) takes the required, keyword-only expected_draft_updated_at, refusing one that is not a str with a RequestArgumentError before anything is sent, and answers a MethodPublishResult, a discriminated union on outcome parsed by MethodPublishResultAdapter: MethodPublished with the new version, MethodPublishUnchanged with the existing latest version when the draft's digest equals it, and MethodPublishRefused with a reason (invalid or not_runnable), a message and the runner's validation verdict, typed as PipelexValidationResult and read member by member as validate reads it. Every arm carries the method, and a body naming no known outcome raises the ApiResponseError of an answer the SDK cannot read rather than reading as a verdict. A publish that produced no verdict is thrown rather than answered: only a draft that differs from the latest version reaches the runner, and a runner that cannot be reached, answers unusably or does not finish within the platform's deadline is a 502 or 503 with nothing written, so a retry is safe, and a retry of a publish that landed while its answer was lost answers unchanged with the version it wrote. A method is stored as one item that keeps room for the summary a publish adds, so a draft that saves can always be published: a creation or a draft write is a 413 request.too_large when it would grow the method past that room, below the store's item limit, and a rename is the same 413 for a name so long it would leave the method too large to publish. A version is numbered from 1, never reused, and holds the draft's tree as it stood, named by its tree_digest, its crate_fingerprint (None when the runner could not resolve the bundle in memory), the runner's version, and when and by whom it was published. list_method_versions pages the summaries newest first (MethodVersionPage, a limit from 1 to 100, 20 by default, and a 422 outside that range), and get_method_version reads one as a MethodVersion, with its files listed as get_method lists the draft's; a version that is not a positive integer is refused with RequestArgumentError before anything is sent. The method routes take a bare id and address the method itself, never one of its versions, and the platform does not parse a suffix there, so mt_x@3 would answer a 404 that reads as a method that does not exist: each route refuses a suffixed id with RequestArgumentError before anything is sent, naming parse_method_selector and get_method_version. A method read carries latest_version and latest_published, both None until the first publish, so the publish state is derived: never published, draft unchanged (draft_digest is not None and equals latest_published.tree_digest), or draft ahead. A version summary's version and a method's latest_version are read strictly: a boolean, a numeric string or a float is never coerced into a version number, and an answer carrying one is an answer the SDK cannot read. The whole-method update_method is gone, replaced by the draft write and the rename. MethodErrorCode (pipelex_sdk.errors) names the codes a method caller branches on in problem.code: pipelex.catalog.update_conflict, pipelex.catalog.being_deleted, pipelex.catalog.not_published (a bare id of a method never published, on a run or tooling route), pipelex.catalog.not_found (an unknown method, or a version it never published), pipelex.org.not_found and pipelex.catalog.copy_too_large.

    The index is paged. list_methods(q=…, published=…, limit=…, cursor=…) returns one MethodPage — items: list[MethodSummary] plus an opaque next_cursor you pass straight back, None on the last page, and no total by design. next_cursor is required on every page model (MethodPage, MethodVersionPage, MethodExamplePage, MethodCopyPage, RunPage), as @pipelex/sdk requires it: the platform always sends it, so a page without it is an answer the SDK cannot read rather than a last page, which would end an iterator early without a word. A MethodSummary is deliberately not a MethodData: no updated_at, which is not in the index projection, and no files, which only get_method lists; a row that inlined its method's sources is exactly what caused the truncation bug paging removed. A method mid-deletion still appears in the list — so a UI can render "Deleting…" — while get_method refuses it with a 409. Query parameters are kept on presence, never truthiness: an explicit empty q or cursor is bad input the API should reject, not something to drop silently into an unfiltered query that reads as working.

    A row carries the method's publish state, and the list filters on it. published=True sends published=true and keeps the methods with a published version, published=False sends published=false and keeps those never published, and None sends nothing and lists both; it combines with q. Each MethodSummary carries latest_version and latest_published (a MethodVersionSummary), both None when the method was never published, and draft_digest, None for an empty draft. All three default to None, so a platform that does not serve them still answers a row the SDK reads. Never published is latest_version is None, the draft unchanged is a draft_digest that is not None and equals latest_published.tree_digest, and the draft ahead is two digests that differ. Both q and published filter after the index read, so a filtered page can come back empty and still carry a next_cursor: an empty page is not the end of the list, and only a None cursor is.

    iterate_methods(q=…, published=…, limit=…) is the idiom for the whole catalog, and there is deliberately no list_all_methods(): an all-at-once helper needs a cap, and a cap is the silent truncation paging removed. It keeps going through an empty page that carries a live cursor, because q and published are post-read filters over a bounded index slice per request, so {items: [], next_cursor: "…"} means "keep going". It stops on a None cursor, and also when the server hands back the cursor it was just sent — checked before yielding, so a stuck cursor never double-counts a page. Past a runaway backstop set far beyond any real catalog it raises PagingNotTerminatingError rather than returning what it has, because a silently truncated list is the bug being fixed.

    delete_method is asynchronous and its return type says so: the route answers 202 with a MethodDeletionAccepted (method_id, deletion_state, deletion_job_id) the moment the platform has claimed the method and terminated its in-flight workflows — the rest of the cascade (runs, events, S3 objects) is enqueued. A returned value means "accepted", never "gone"; completion is the row disappearing from list_methods, not any field of that body. It used to be annotated -> None with a docstring promising an empty synchronous delete, which is a misleading contract around a destructive operation. The claim is a conditional write, so a double-clicked delete is a 409 pipelex.catalog.being_deleted rather than a second cascade over the same runs. The erasure deletes the method's published versions with the rest.

  • Method examples — create_method_example(id, name=…, inputs=…, run_id=…) / list_method_examples(id, limit=…, cursor=…) / iterate_method_examples(id, limit=…) / get_method_example(id, example_id) / update_method_example(id, example_id, name=…, inputs=…) / record_method_example_latest_run(id, example_id, run_id=…, input_revision=…) / delete_method_example(id, example_id) / save_assistant_method_example(id, name=…, inputs=…, example_id=…, input_revision=…), on /v1/methods/{id}/examples. An example is a named set of inputs saved on a method, shared across the organization, with an input_revision that rises each time its inputs change and the latest_run_id of those inputs, which an inputs edit clears. record_method_example_latest_run records a run against the revision whose inputs it used and answers a MethodExampleLatestRunResult discriminated on outcome, MethodExampleLatestRunRecorded or MethodExampleLatestRunStale, each carrying the example: a run that outlasted an edit is a verdict, never raised, and an answer off the union is one the SDK cannot read. kept_by_assistant marks the example the build assistant keeps current with its test inputs, until a person's first inputs edit makes it theirs, and save_assistant_method_example is the assistant's save, a MethodExampleAssistantSaveResult discriminated on outcome, MethodExampleAssistantKept when the named example still held the revision read and MethodExampleAssistantCreated otherwise, so a person's edit is never written over. The list is paged oldest first by an opaque cursor, and a page may be short while next_cursor is set; iterate_method_examples follows it, and refuses a method id at the call rather than at the first __anext__. MethodExample reads its fields strictly (StrictStr, StrictInt within 2^53 - 1 either way, StrictBool), as @pipelex/sdk checks them, so both refuse the same answers. Every method takes a bare catalog id, and the client refuses an example id that is empty, . or .. or holds a lone surrogate, a create whose name or inputs is None or whose run_id is given but empty, an edit naming neither name nor inputs, a latest-run write without a run id or a positive integer revision, and an assistant's save naming only one of example_id and input_revision before sending. See method-examples.md.

  • Method copies — copy_method(id, destination_org_id=…, name=…, expected_draft_updated_at=…, idempotency_key=…) / list_method_copies(id, limit=…, cursor=…), on /v1/methods/{id}/copy and /v1/methods/{id}/copies. copy_method makes a new, unpublished method in an organization the caller may change methods in, the method's own for a duplicate, holding the original's draft tree, its form inputs and its examples, the stored files they name copied into the destination, and answers a MethodCopyResult discriminated on outcome, MethodCopied with the copy as method and what was copied, or MethodCopyRefused with the references of a bundle naming storage the destination cannot read or, in a duplicate, a file under the original's run files, which erasing the original would remove; MethodCopyResultAdapter reads it, as MethodPublishResultAdapter reads a publish, and an answer off the union is one the SDK cannot read. Its body is closed, destination_org_id with name and expected_draft_updated_at when given, and its idempotency_key is sent as the Idempotency-Key header through the same context variable _send reads for a start, set around the copy's one request and checked by the same _idempotency_key_of. list_method_copies answers one MethodCopyPage of MethodCopyMarker rows, as list_method_versions does, with no iterator. Every method read carries copied_from, a MethodCopiedFrom naming the original on a copy and None otherwise. MethodErrorCode names the copy's own codes, ORGANIZATION_NOT_FOUND and COPY_TOO_LARGE, whose limit member MethodCopyLimit reads. Both take a bare catalog id. See method-copy.md.

  • Organizations — list_memberships() → MembershipsResponse (memberships + active-org feature flags); create_organization(name) / rename_organization(org_id, name) → Membership. A membership's role_in_org is an OrgRole or the raw string of a role this SDK does not know, so a match over it needs a branch for a plain string; what a role may do is the platform's to decide, and a call refused to the caller's role raises ApiResponseError. Organization switch is out of scope (a WorkOS session op, not a /v1 route).

  • Organization branding — get_branding() (GET /v1/branding, any role, web session or API key) and update_organization_branding(org_id, OrganizationBrandingUpdate) (PATCH /v1/organizations/{org_id}/branding, an admin's write) → OrganizationBranding: the organization's name, its light and dark colour sets (BrandingColors) and its square and wide logos (LogoDescriptor). Only the update's set fields are sent, an explicit None clearing one; a refused logo or colour is named in ApiResponseError.errors, by slot or by dotted key, a logo the platform measured with a LogoRefusalCode. See organization-branding.md.

  • Caller webhooks — list_run_webhook_deliveries(run_id), get_run_webhook_delivery(run_id, delivery_id) and replay_run_webhook_delivery(run_id, delivery_id), on /v1/runs/{id}/webhook-deliveries, /v1/runs/{id}/webhook-deliveries/{delivery_id} and its /replay, read a run's delivery log and send a settled delivery again, any member by web session or API key; get_webhook_secret(org_id), on GET /v1/organizations/{org_id}/webhook-secret, and rotate_webhook_secret(org_id, idempotency_key=…), on POST /v1/organizations/{org_id}/webhook-secret/rotate, read and rotate the organization's signing secret, an admin's routes from a web session only. Their models, the run.finished event's models and verify_webhook, which needs no client, live in pipelex_sdk/webhooks.py. See webhooks.md.

  • Billing — get_subscription(), list_plans(), list_invoices(), create_checkout(plan). change_plan(plan) and get_billing_portal() surface a 409 conflict (ApiResponseError.code) when there is no subscription yet — start one via create_checkout first.

  • Pipelex API keys — list_pipelex_api_keys(); create_pipelex_api_key(label) and rotate_pipelex_api_key(id) return the plaintext api_key once; revoke_pipelex_api_key(id). Creation surfaces a 409 pipelex_api_key_limit_reached when the per-account limit is hit. Rotation sends no body.

  • Onboarding — submit_onboarding(OnboardingSubmission) (POST /v1/onboarding/submit, empty 2xx body); absent optional fields are dropped.

  • Storage — resolve_storage_url(uri) → presigned URL for one reference; resolve_storage_urls_bulk(uris) → one verdict per reference through POST /v1/resolve-storage-url/bulk, the route the artifact stack is built on; upload(UploadInput) → the stored file handle, UploadedFile, with the file's uri, its filename and the file_id of its record, the input's kind (UploadKind) and, for a test file, its method_id recording it as a person's file or as a test input made for one method, sent only when given. The higher-level upload_file / prepare_inputs preparation surface built on top of upload is now available — see input-preparation.md — and takes the same pair, upload_file as its kind and method_id arguments and prepare_inputs as its upload (UploadKindOptions), each checked before any file is read, a test file the upload route refuses for the caller's role or for its method raising TestFileRefusedError (files.md); its download twin is the artifact stack below.

  • An organization's files — list_files(…) / iterate_files(…) (GET /v1/files), get_file(file_id, uses_limit=…, uses_cursor=…), get_files_by_uri(uri, run_id=…), rename_file(file_id, name), link_files(files) (POST /v1/files/links), request_file_archive(file_ids=… | run_id=…, name=…) / get_file_archive(archive_id) (/v1/files/archives), list_run_files(run_id, limit=…, cursor=…) (GET /v1/runs/{id}/files) and copy_file_into_draft(method_id, file_id=…, path=…, expected_updated_at=…) (POST /v1/methods/{id}/draft/files/from-file). A file is the record the platform keeps of a stored object, with the name a person sees, a kind, a type and its provenance; FileSummary is a list row and FileRecord the whole record. The list's repeated filters are named formats and media_types, plural because format is a Python builtin, and sent under the API's singular names; an empty one raises RequestArgumentError before any request, since it would be sent as no parameter and list everything, and a bare str raises TypeError. _repeated_filter copies the values once before checking them, so a generator is neither exhausted by the check nor dropped, and iterate_files checks its filters at the call and sends those copies with every page through _follow_cursor, as the other iterators do. link_files takes FileLinkTargets, a frozen closed model checked at construction to name exactly one of file_id and uri, and raises TypeError for an item of any other type, a plain dict included, and answers one FileLinked or FileLinkRefused per requested file, which FileLinksResult checks against the request's count through the validation context. An archive is queued and then read until ready or failed, with no waiting helper; a selection past a cap is a 422 file_archive_too_large whose problem document's limit FileArchiveLimit reads. The copy into a draft answers a DraftFileFromFileResult discriminated on blob. The values a later platform may extend are read open (FileKind | str, the record's role and state, a use's holder type, a run file's mark, an archive's source and failure code), while the fields a caller branches on, an archive's state, a run's files' state and a copy's blob, are read closed, as @pipelex/sdk checks them. See files.md.

  • Run records — list_runs(method_id=…, …) / iterate_runs(method_id=…, …) / get_run_detail(run_id) (the catalog-style reads, distinct from the lifecycle status/result routes); update_run(run_id, UpdateRunInput) (admin/manual status patch, empty 2xx body).

    This list is paged too. list_runs(method_id=…, created_from=…, created_to=…, created_by=…, status=…, limit=…, cursor=…) returns a RunPage of RunHistoryItem rows with the same opaque-cursor contract as MethodPage, every argument keyword-only and optional. The method is one filter among the others: method_id lists that method's runs, and without it the page holds the organization's runs of every method, newest first, the other filters applying exactly as with a method. A cursor is bound to the list that issued it, so the cursor of one method's runs is refused on the organization's and the other way round; pass it back with the same method_id. A row is what a history shows and nothing more: pipeline_run_id, method_id, the bare catalog id of the method the run ran, created_by_user_id, the canonical user id of the member who started it, status, created_at, finished_at, pipe_code, error, and method_version and source_digest, which version of the method the run ran (a number, "draft", or None for an inline source) and the SHA-256 of the sources it sent as text. A row also carries results_stored, which says whether get_run_result can read the run's results: True exactly when the run is COMPLETED with its results stored, and False on every other row. Only while the run is still queued or running (PENDING, STARTED, RUNNING) does False mean "not yet". On a run that ended any other way than COMPLETED it is False for good, there being no results to read, and on a COMPLETED run it is False for good when the result went inline to the caller that started it, as a blocking execute run's does, and the results read of such a run answers 404 run.results_not_stored, so a history asks for no results on that row. It defaults to None, which a row from a platform that predates the field reads as, and which says nothing either way: it does not mean False. It is read strictly, as method_version is: a value that is not a boolean, such as the string "true" or the number 1, is never coerced, and the row carrying it is an answer the SDK cannot read. The history lists the runs of every version and of the draft together, by the bare id. method_version and source_digest default to None on every run record (RunPublic, RunRead, PipelineRun, RunDetail, RunHistoryItem), since a run recorded before the platform recorded them carries neither. On every run record, method_version is read strictly: a value that is neither an integer nor "draft", such as a boolean, a numeric string or a float, is never coerced into a version, and the record carrying it is an answer the SDK cannot read. A list across methods names each row's method from the catalog and its author from the organization's members, by these ids. The organization, the workflow id and the result prefix are not on the list; get_run_detail serves the whole record when a run is opened. created_from / created_to are instants — ISO-8601 with a UTC offset — and inclusive; they are index key conditions rather than filters, so a bare date or a naive timestamp is refused by the platform as request.invalid, surfaced as ApiResponseError. created_by="me" (typed Literal["me"]) sends created_by=me and keeps the runs the caller started, which the platform resolves from the authenticated identity; it takes no other value. status, any iterable of RunStatus, read once so that a generator works too, is sent as one repeated status= parameter per status, deduplicated into the enum's order so the same filter always builds the same query, and keeps the runs in any of them: the caller's own latest unfinished run is created_by="me", status=[RunStatus.PENDING, RunStatus.STARTED, RunStatus.RUNNING], limit=1. An empty status would be sent as no parameter at all, which lists every status, so it raises RequestArgumentError before any request, as does a name that is no RunStatus; a bare str raises TypeError, since read as an iterable it would name its characters. The platform may apply these two after its index read, so a filtered page can hold fewer runs than limit, or none, and still carry a next_cursor. Worth knowing and not obvious from the route: every /v1/runs* product route sits behind the platform's surface-access gate, which for API-key auth demands the ff_api_keys feature flag and fails closed with a 403 — so a 403 here means "flag", not "wrong key".

    The iterators run one loop. iterate_methods, iterate_runs and iterate_method_examples all go through _follow_cursor, so they cannot drift apart: each continues through an empty page with a live cursor, since the methods list filters after the index read, the runs list may apply its filters after its index read, and the examples list stops a read at 1 MB, stops on a None cursor, and stops when the server hands back the cursor it was just sent, checked before yielding. They share the same runaway page ceiling and raise PagingNotTerminatingError at it, which also bounds a cursor that cycles across two or more values (c1 → c2 → c1), a repeat the adjacent-cursor check does not see; tracking every cursor seen would cost unbounded memory for the same protection. The run iterator used to stop on an empty page, on the reasoning that its date bounds are index key conditions; the created_by and status filters made that wrong, since a filtered run page can be empty and still not be the end. iterate_runs checks its status at the call, as iterate_files checks its filters, so a bad one raises before the iterator exists and a one-shot iterable, checked and copied once, filters every page rather than only the first.

    Fields the platform genuinely serves as null are typed nullable, and the ones it always serves are required. On RunHistoryItem, method_id and created_by_user_id are required strings, since every run on the list is a run of a method started by a member, so a row without either is an answer the SDK cannot read; pipe_code is None for a run that let the bundle's main_pipe decide, and error is None on every run that did not fail. On PipelineRun, the whole record and the base of RunDetail, method_id is None for an ad-hoc run from an inline bundle, which belongs to no stored method; pipe_code is None for a run that let the bundle's main_pipe decide. org_id, created_by_user_id, and error, the run's own problem or None, read exactly as on the status read, join them. RunDetail, returned only by get_run_detail, adds mthds_contents and inputs: what the run actually executed, and the only record of it, since a method edited since the run no longer describes what happened. It also carries tree_digest, the digest of the method tree the run sent as files, and crate_fingerprint, the crate the runner loaded it into, each None for a run that recorded none; a run's source_digest is not a tree digest but the SHA-256 of the sources it sent as text, 64 hex digits with no prefix, and None for a tree holding a Base64 or a hash entry. Both are left out of the list and the polled status on purpose — their cost scales with page size and poll rate respectively.

Artifact stack (pipelex_sdk/artifacts.py + pipelex_sdk/artifact_models.py)

The download twin of input preparation, and the Python twin of @pipelex/sdk's src/artifacts.ts: locate_artifacts walks a value for pipelex-storage:// references and says every $-rooted path each one sits at, collect_artifacts is the same walk's bare references, resolve_artifacts mints fresh links for a whole list through the bulk route (chunked at its bound), fetch_artifact yields one bounded stream, and download_artifacts saves a run's produced files under a directory as a produced verdict, each file named after the field it fills by artifact_filename's rule. The operations take a Protocol rather than the client, so PipelexAPIClient satisfies them structurally and a test injects a fake; the client also carries resolve_artifacts, fetch_artifact and download_artifacts as methods, the way it carries upload_file and prepare_inputs. The whole contract, the options and the error taxonomy are in artifact-download.md.

The shapes are owned by pipelex_sdk/artifact_models.py — the scope enum, the wire items, the location a walk answers, the options and the verdict, plus the public defaults — beside product_models and crate_models. That split is not cosmetic: pipelex_sdk/errors.py types ScopeUnavailableError.scope and ArtifactAuthenticationError.verdict with two of them and the operations module imports those errors, so keeping the shapes in the operations module would be an import cycle (reportImportCycles is an error here). The JS twin needs no such split, TypeScript tolerating the cycle.

The object store is fetched on its own httpx client, never the API client's: no Authorization header of ours can ride along to the store, redirects are refused rather than followed, and the call's timeout is both a budget over the whole exchange and httpx's per-operation timeout. _new_storage_client is the one seam the module opens, which is where the unit suite injects an httpx.MockTransport.

Health probe

health() → GET {origin}/health. The one route served at the origin, NOT under the /v1 prefix — the origin is derived from the base URL (_origin_of, exposed as self.origin_url), so a base URL of https://api.pipelex.com/v1/... still probes https://api.pipelex.com/health. It is out-of-protocol: the MTHDS Protocol defines no health route, and /health is neither a protocol nor a product surface. It sends through _send like every route, so an unreachable origin is an ApiUnreachableError, and a non-2xx answer is an ApiResponseError naming /health (API GET /health failed (503): …), whose verdict the fallback reads from the status, as @pipelex/sdk's health does.

The pipelex-sdk command (pipelex_sdk/cli.py + pipelex_sdk/command/)

The package publishes one console script, pipelex-sdk = "pipelex_sdk.cli:main", which uvx pipelex-sdk run … and uvx pipelex-sdk script … run (cli.md). It sits at the top of the dependency graph: nothing in the SDK imports pipelex_sdk.cli or pipelex_sdk.command, so the library never loads it (tests/unit/test_cli_packaging.py holds that in a fresh interpreter), and the command reaches the SDK only through its public modules — PipelexAPIClient (pipe_io, prepare_inputs, start_and_wait with on_starting and on_started, get_method), the typed errors it presents, and mthds' render_inputs_template. It adds no dependency.

cli.py is the executable, and the only module that touches sys and os.environ: it hands the command its environment and standard streams as a CommandIO, writes text as UTF-8 whatever the locale, and exits with the code command.main.run_command returns. Inside command/, main dispatches and turns every failure into sentences through present (never a traceback, the one broad except being the command's top level); run and script hold the two subcommands; args reads the flags; method, bundle, inputs and environment make the checks that need no request; source names the method as each route takes it and makes the one pipe I/O call; json_text prints the result as JavaScript's JSON.stringify(value, null, 2) does; loop runs the command's one event loop.

Every check that needs no request runs before the command's one event loop starts, so a mistake costs nothing and a Ctrl-C while stdin is read stops at once. The waiting happens on that loop, which command.loop.run_until_done runs as asyncio.run would, turning Ctrl-C into a cancellation of the command's task and raising KeyboardInterrupt once it has unwound, but with a default executor of daemon threads it never waits for, so a file read abandoned by an interrupt does not hold the process; command.loop.before_request lets an interrupt that landed while an answer was read stop the command before its next request. command.io.Progress holds what the command then says, set by each stage before it begins, by on_starting right before a request that may create a run, and by on_started once the run exists.

The command is the twin of @pipelex/sdk's, which it shares no code with. The two are held together by the recorded case table tests/fixtures/cli-cases.json, a copy of the JavaScript SDK's source table written by make shared-files at the repository root, which tests/unit/test_cli.py runs case by case through the real client and an httpx.MockTransport. The case table settles behaviour; a case this command cannot pass is settled by changing the source table for both commands.

Out of scope

  • The /v1/build/* helpers. Neither SDK carries them any more. This one never shipped build_output, build_runner, concept or pipe_spec, and removed build_inputs, which shipped in 0.5.0, once prepare_inputs, its only caller, moved onto the input-form descriptor (read from validate then, from pipe_io now). @pipelex/sdk retired all five of its wrappers together once no consumer was left. The workspace is retiring the routes themselves; a caller that wants an inputs template projects it from the descriptor pipe_io returns, with mthds.protocol.inputs_template.
  • Organization switch (a WorkOS session operation, not a /v1 route).
  • A reader of ~/.pipelex/.env (or the .env under PIPELEX_HOME), the file pipelex login saves the key to. The client reads its arguments and the process environment only, as the JS SDK does, and the pipelex-sdk command reads no .env file on purpose (cli.md, "The environment"), so loading that file is the caller's step ("Credentials & configuration" above).
  • A synchronous client facade.

Versioning

__version__ (pipelex_sdk.version) is read from the installed distribution metadata via importlib.metadata, so there is no hardcoded constant to drift from the pyproject.toml source of truth — the analogue of the JS SDK's SDK_VERSION guard, but with nothing to keep in sync by hand. tests/unit/test_version.py asserts the resolved value matches the version declared in pyproject.toml, catching a stale install (e.g. an editable tree whose metadata was not refreshed after a bump).

Parity with @pipelex/sdk

This SDK is a port of the TypeScript @pipelex/sdk (PipelexApiClient) and tracks it closely, but it is not surface-complete, and this section is where the gaps are named. The Checkpoint-5 parity audit walked the JS src/client.ts, src/index.ts (the public barrel), and docs/architecture.md, plus a field-by-field sweep of runs.ts / product-models.ts / models.ts; it concluded surface-completeness, and that conclusion went stale as the JS SDK grew. The honest list of what has no Python counterpart today:

  • Tooling routes — lint, format (resolve and codegen shipped in 0.8.0 with the method selectors and are not gaps).
  • The upload grant — @pipelex/sdk's requestUploadGrant (POST /v1/upload/grant), describeUpload and uploadWithGrant let a browser page that must not hold the API key send a file straight to storage, the file's SHA-256 naming the object. This SDK has no grant path, by design rather than by delay: it holds the key wherever it runs, so it uploads through POST /v1/upload, where the platform hashes the bytes itself and answers the file's file_id. What uploadWithGrant decides from storage's answers therefore has nothing to apply to here, the 412 of a create-only PUT read as stored for a grant that names its bytes among them; the shared RejectedAssetCode and UploadTransportCode still name those answers' codes, so a consumer of both SDKs branches on one vocabulary.

The pipelex-sdk command is not a gap: it is at parity with @pipelex/sdk's, both running every case of one recorded table ("The pipelex-sdk command" above).

Each stays deferred rather than silently missing. Everything else — the protocol routes, the durable lifecycle, the whole product surface, and the errors with their verdicts — does have a Python equivalent.

Methods — everything outside the gap list above has a counterpart: protocol (execute, start, validate, models, version), durable lifecycle (get_run_status, get_run_progress with is_fresher_run_progress, get_run_result, wait_for_result with its on_progress, start_and_wait, the private _supports_run_lifecycle / _execute_blocking), the whole product surface (profile, methods CRUD with paged listing and the paged-list iterators, a saved method's tree with its uploads, its limits and write_method_tree, a method's examples with the latest-run verdict and the assistant's save, an organization's files with their links, archives, a run's files and the copy into a draft, organizations and their branding, billing, Pipelex API keys, gateway key, onboarding, storage, run records with get_run_detail), the crate routes (resolve, codegen, pipe_io) with the verbatim write_codegen_tree and the offline run_codegen_check, the model reference check (check_model_reference), the input-preparation surface (upload_file / prepare_inputs, taking all three method selectors, reading its signature from the input-form descriptor pipe_io returns for the pipe the server selects, and recording its uploads as a method's test files with TestFileRefusedError for the refusals), and health. The method selectors (method_ref / method_id) match the JS v0.16.0 surface across the run and tooling methods, with one signature-shape divergence: JS validate takes a ValidateMethodSelector object in place of its first argument, while Python takes method_ref= / method_id= keyword parameters — same wire, same XOR, idiomatic per language.

The run-results surface — RunResults tracks @pipelex/sdk 0.20.0 field for field, and is at parity with it. Every field is declared, with the two-path semantics run-results.md states: graph_spec (lifted on the blocking path instead of written None), graph_assembly_error, the three I/O artifacts typed from mthds.protocol with pipe_io_artifacts_error beside them, the usage pair, working_memory (the hosted artifact relayed as its own key, the blocking one lifted off pipe_output.working_memory), and pipe_output; and the usage pair's fold, summarize_usage, with the summary types it returns. What stays different is idiomatic and deliberate: a key the hosted body did not carry is absent from model_fields_set where the JS reads undefined, and the page says how to read that.

The progress snapshot — RunProgress and its parts track @pipelex/sdk's RunProgress member for member, and get_run_progress, WaitForResultOptions.on_progress and is_fresher_run_progress its getRunProgress, onProgress and isFresherRunProgress; the Python SDK follows the JS one on this wire shape and never moves first. Two differences are deliberate. An optional member the JS read relays unchecked is read leniently here, None when its value does not fit its type, because a typed model cannot relay a value of another type and a decoration must not fail the read; the members the JS read checks are the ones this read holds strictly. An unreadable snapshot raises the ApiResponseError every route of this SDK raises for one, a body that is not the answer the route returns with the ValidationError naming the member as its __cause__, where the JS message names the member itself. The progress read inside the wait is bounded by the time the wait has left, with the deadline checked again before it and the elapsed time taken after it, so a slow or hanging progress read never holds wait_for_result past its timeout_seconds.

Models — field-for-field across the run-lifecycle types and the product wire models. Deliberate idiomatic ports (not gaps): milliseconds → seconds (interval_seconds / timeout_seconds / elapsed_seconds); the JS AbortSignal → Python asyncio cancellation (no signal field, and no aborted flag on the download verdict — a cancelled download_artifacts raises CancelledError with its partial files already unlinked); the JS artifact request object → DownloadArtifactsOptions beside an explicit dir_path (dir being a Python builtin) and the raw bulk call named resolve_storage_urls_bulk after its route rather than resolveStorageUrls, so it cannot be misread as the single-reference resolve_storage_url; the returned Response of fetchArtifact → an async context manager, because an httpx stream is only live inside its own block; JS string-union types promoted to StrEnums (OrgRole, the onboarding fields) with identical wire values, a union the JS twin reads open (OrgRole | (string & {})) being read open here too (OrgRole | str); response models are extra="allow" for forward-compat. The Pipelex validation narrowing is owned here (pipelex_sdk.validation_models), narrowing mthds's neutral verdict bases (the resolved follow-up #9); the brand-neutral Dict* wire concretes (DictRunResultExecute, and the DictPipeOutputAbstract / DictWorkingMemoryAbstract pair that types RunResults.pipe_output and RunResults.working_memory) are reused from mthds by inheritance or by import — they are a shared wire contract the pipelex runtime also builds on — rather than duplicated as pipelex-sdk-js does. One addition runs ahead of the JS SDK: write_codegen_tree has no @pipelex/sdk counterpart, because the JS writer lives inside pipelex-starter-js's own harness, mixed in with project policy; the byte-fidelity contract is small and load-bearing enough to belong to the SDK, where every consumer shares one correct implementation. The drift check goes the other way and takes a different shape on purpose: @pipelex/sdk's runCodegenCheck is pure — the caller walks its own tree and hands in the text — because that module must stay free of Node builtins for a browser bundle, and its doc consequently loads the caller with obligations (walk the whole tree, do not reformat, decode strictly) whose every breach yields a wrong verdict rather than an error. Python has no such constraint, this package already does filesystem work, and pipelex's own surface takes a root — so run_codegen_check(root=…) takes one too. Every caller obligation becomes the library's, the verdict is provable against pipelex by calling both with the same directory, and it composes with write_codegen_tree(report, output_dir=…) as the same path in and out. One divergence worth naming: the page envelopes keep the wire's snake_case next_cursor, a run's files' RunFilesPage included, where the JS mirror renamed it nextCursor for its own consumers. Two more are idiomatic: list_files names its repeated filters formats and media_types where the JS names them format and mediaType, since format is a Python builtin; and a malformed FileLinkTarget or a selection given as a bare str is refused by pydantic at construction or by a TypeError, where the JS checks plain objects at the call and throws RequestArgumentError. The upload's kind is the same pair in an idiom of its own: upload_file takes kind and method_id as keyword arguments and prepare_inputs takes an UploadKindOptions model as its upload, where the JS reads both from plain objects; the rules the upload route holds the pair to are still checked at the call, an InputPreparationError naming upload.kind or upload.method_id, while an UploadKindOptions built with a kind that names no UploadKind, or a member of the wrong type, is refused by pydantic as it is built; a kind given as its string is read as the UploadKind it names. A saved method's tree is read, hashed, uploaded and written through mthds's own mthds.method_tree and blob routes here, where @pipelex/sdk carries a mirror of the mthds npm package's unreleased tree-sending helpers in its src/method-trees.ts, because its public mthds/protocol subpath does not export them yet; both send the same tree by the same inline rule.

Errors — every class is owned in pipelex_sdk/errors.py under its @pipelex/sdk name, and states the same problem, the shared case file tests/fixtures/sdk-problems.json holding the two to it (errors.md): RequestArgumentError, ApiUnreachableError, PipelineExecuteTimeoutError, RunFailedError, RunTimeoutError, RunLifecycleUnavailableError, MissingMainStuffError, PagingNotTerminatingError, the input-preparation and artifact families and CodegenLockError; ApiResponseError is owned here as a narrowing of mthds's own, with the same members as @pipelex/sdk's, its problem above all; RunStillRunningError refines mthds's class of the same name, which the inherited execute raises. MethodFileTransferError is raised by both for a file of a saved method's tree that could not be uploaded or downloaded. WebhookVerificationError and WebhookEventUnreadableError are the caller-webhook receiver's, raised by verify_webhook and held to the same refusal codes by the shared case file tests/fixtures/webhook-events.json (webhooks.md). Python alone has CodegenError, the base of CodegenLockError, which the codegen tree writer raises for a tree it refuses, and FieldNotIncludedError. ClientAuthenticationError is not ported: the JavaScript SDK removed it, since its client never raised it, and in Python it already lives in mthds.runners.api.exceptions — importable directly if ever needed, with no barrel here to re-export it through.

Resolved parity flags (Checkpoint 5):

  • PipelineExecuteTimeoutError / execute gateway-timeout translation (Phase-1 flag) — implemented (see "execute override" above), closing the one genuine feature gap rather than deferring it.
  • health error regime (decision #5) — unified: a non-2xx health answer raises ApiResponseError, as @pipelex/sdk's health does, so its verdict is decided like every refusal's.
  • Protocol-route error regime (the Phase-3 validate flag, and decision #5's inherited-protocol vs product split) — unified: every /v1 route raises ApiResponseError, mthds's own error narrowed, as every @pipelex/sdk route does (see "Error regimes" above).

Conscious exclusions: the surfaces listed at the top of this section, and the organization switch (a WorkOS session op, not a /v1 route).

Intentional divergences from the JS SDK (Python house style / clean inheritance): no barrel (__init__.py stays empty; import via full paths); inheritance on MthdsAPIClient rather than the JS composition-of-types; async-only; __version__ derived from installed metadata rather than a hand-synced constant.