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).
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/versioninherited as-is;execute/start/validatelightly 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.
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.
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.
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_keyargument →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_urlargument →PIPELEX_BASE_URL→ the hosted defaulthttps://api.pipelex.com. Presence semantics at both layers (also the JS??chain): an explicit empty string — abase_url=""argument or a set-but-emptyPIPELEX_BASE_URL(e.g. an unfilled CI secret) — reaches the host-only validator and raisesRequestArgumentError, its problemclient.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.
- Async-only — httpx
AsyncClient,async defthroughout. No sync facade in v0.1. - No barrel — package
__init__.pyfiles 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.
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 toApiUnreachableError: anhttpx.ReadTimeoutorhttpx.WriteTimeout, a request that reached the API and outlasted its time limit, getsnetwork_code="ABORT_TIMEOUT", and any otherhttpx.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) getsnetwork_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'sexecute,start,validate,modelsandversioncallself._send, so they meet the same mapping as the run reads, the product routes andhealth. Non-2xx interpretation stays with the caller._send_and_read— the request itself, which_sendmakes here instead of calling the base's_send, for two reasons. The base reads the body inside httpx'srequestcall, so a body that does not decode, such as a broken gzip stream, surfaces as anhttpx.DecodingErrorthat has lost the answer's status and headers; and the base takes no per-request headers, so it cannot send the caller'sidempotency_keyas theIdempotency-Keyheader of astartor acopy_method. This method builds the base's per-request headers (Accept,User-Agent,Content-Typebeside a body), adds the key when one is set in the_IDEMPOTENCY_KEYcontext variable, whichstartandcopy_methodset around their request as_REQUEST_TIMEOUT_OVERRIDEcarries a time limit, then streams the answer and reads its body itself. A body that does not decode raises theApiResponseErrorthat_undecodable_answerbuilds: the answer's status and headers read as an answer with an empty body, so a2xxisclient.response_unreadable, orclient.outcome_unknownon a request that may create a run, as@pipelex/sdkreads it (errors.md), thehttpx.DecodingErrorkept as__cause__. A key that is not 1 to 255 visible ASCII characters is refused with aRequestArgumentErrorbefore anything is sent, the blockingexecutefallback ofstart_and_waitsends 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.pyguards the fork: it holds an unkeyed request to the headers the base's_sendsends 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 callsuper()._sendagain._request_product— the product-route path. Serializes the body withpydantic_core.to_json(supporting PUT/PATCH/DELETE as well as GET/POST), and a body holding a lone surrogate, whichto_jsonrefuses, asJSON.stringifywrites it, the surrogate escaped (_json_body), so the platform refuses the text it cannot store with its own422, as it does when@pipelex/sdksends the same body; uses the management-call timeout, maps a non-2xx response toApiResponseError, and reads a 2xx body with thereadits route passes (a model'smodel_validate, an adapter'svalidate_python)._request_product_without_answerserves 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,modelsandversion. A body that is not UTF-8 or not JSON, an empty one included, and JSON itsreadrefuses with a pydanticValidationError, raise theApiResponseError_unreadable_answerbuilds, 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_sendrecords 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_sendwith a mock records no answer, so it drives these routes through anhttpx.MockTransportinstead.
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>.
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_methodorget_method_version, the upload targets ofPOST /v1/blobs/missing, the file links oflink_filesand an archive fromrequest_file_archiveorget_file_archive— is read withcarries_links: an error built from it has every link redacted inresponse_body(_redact_signed_links,@pipelex/sdk'sredactSignedLinks), 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 anexceptblock keeps the failure it replaces as its__context__evenfrom None. The base'smissing_blobsreads its answer through_parse_answerand_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 andhealth— raises a typedApiResponseErroron a non-2xx answer. It ismthds's ownApiResponseErrorrefined (class ApiResponseError(PipelexRequestError, mthds.runners.api.exceptions.ApiResponseError)), so a handler written against the standard's client catches it too and reads the sameproblem. Consumers branch onproblem.code, never on the HTTP status or the wording of a message, and readproblem.effective_domainfor who must act andproblem.effective_retryfor whether sending the request again can succeed. A hosted refusal'spipelex.*code is one the registry names, and theMethodErrorCodeandIdempotencyErrorCodeenums 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 carriesstatus,status_text,response_body,headers(lower-case names),request_url,api_urlandrequest_id, the problem's own or else theX-Request-IDheader. - Transport failures (DNS/connect/TLS/timeout) raise
ApiUnreachableError(subclass ofPipelexRequestError, itself aPipelineRequestError, and of the standard'sApiTransportError) withapi_url,request_url,network_codeand itsproblemon every route — the inherited protocol routes, the run status and results reads,wait_for_resultandstart_and_wait, the product routes andhealth— because all of them send through the_sendoverride. A caller that catchesPipelineRequestErrortherefore never meets httpx's own transport exceptions. The standard's client reads a request that got no answer as its ownApiTransportErrorin_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 isclient.unreachablenaming the base URL,client.timeoutnaming the time limit when the client's own limit ran out (network_codeABORT_TIMEOUT), andclient.outcome_unknownfor a request that may create a run unless the failure proves the request never left (pipelex_sdk/transport_reading.py, the twin oftransport-reading.ts). - Arguments refused before sending raise
RequestArgumentError,client.argument_invalid: no run source given toexecuteorstart, a protocol or reserved arg inextra, 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 suffixedmethod_idbeside the method's tree, a selector rule ofvalidate, an artifact selection that is empty or names an unknown artifact, a suffixed id given to a method route, apublish_methodtoken that is not a string, aget_method_versionversion 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 astr, since the URL would resolve it into another route's path, or that holds a lone surrogate, which a URL cannot carry, acreate_method_examplewhosenameorinputsisNoneor whoserun_idis given but empty or not astr, anupdate_method_examplenaming neithernamenorinputs, arecord_method_example_latest_runwithout a run id or a positive integerinput_revision, and a valueparse_method_selectorrefuses. A base URL that is not host-only is one too,client.not_configured, since it typically comes fromPIPELEX_BASE_URL. The base client raises refusals of its own (nothing to run, a protocol arg inextra, a request timeout it refuses) as the standard'sclient.argument_invalidorclient.not_configurederrors, and its 202 degrade asmthds'sRunStillRunningError; the client raises them again asRequestArgumentErrorand as this SDK'sRunStillRunningError, 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.
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.
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 cloneneeds no catalog — which is exactly why it is a runner argument and not a hosted one. Onapi.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 a422naming the key.
The reasoning below covers both and generalizes to every extension argument that follows:
- A named parameter, not an
extraentry. Typing its own stack's arguments is the one job this client exists to do;extrastays 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_extensionsfolds them into theextramapping handed tosuper().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 rideextra, since they are no run source (Caller webhooks below). - The guard is per layer.
method_ref,method_idandcallback_urlsare rejected insideextrahere (a smuggled copy would overwrite the validated named option and bypass the exclusivity checks —extramerges last), and must never become reserved inmthds: a protocol client talking to another vendor's server has no business rejecting that vendor's arguments. method_refpairs with NOTHING. It is a complete run source — the fetched package carries its.mthdsand its entry pipe — so combining it with the method's tree (files,treeorcontents) or withmethod_idraisesRequestArgumentErrorbefore 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_codebeside it is legal: it overrides the manifest'smain_pipe. This SDK does not name the bundles that may rideextra(mthds_contents,bundle_b64), so that arm of the server's exclusivity has no client-side twin here.method_idis 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 keyGET /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_idmay name a version. A baremt_…runs the method's latest published version,mt_…@<n>the fixed versionnandmt_…@draftits 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 a422, andexecuteandstartrefuse it first withRequestArgumentError, before anything is listed, uploaded or sent, whichever form the tree takes; a bundle ridingextrais left to the platform, as formethod_ref. Thestartack (PipelexRunResultStart) and the blockingPipelexExecuteResultcarrymethod_version, the version amethod_idrun 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_selectorsplits a selector into its bare id and version locally and refuses a malformed suffix withRequestArgumentError, for a caller that reads the method by its bare id; itsParsedMethodSelector.versionis strictly anint,"draft"orNone. - Provenance comes back typed. A
method_refrun'sstartack isPipelexRunResultStart(pipelex_sdk.runs) carryingmethod_provenance: MethodProvenance | None—{address, tag, commit_sha}, the SHA being what keeps the run explainable when a tag moves — andPipelexExecuteResultdeclares the same field on the blocking path. Both areNonefor inline-source andmethod_idruns. The server fetches the package before the 202, so provenance rides the ack; amethod_refstart keeps the wholerequest_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/sdkwidens its own start budget formethod_ref. - Both ride the blocking fallback too.
start_and_waitdegrades toPOST /v1/executeagainst a runner with no run store, and the selectors go with it — amethod_refrun must run the same fetched package there, and a barepipelex-apianswers the422that namesmethod_idinstead 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), raisingRequestArgumentErrorrather 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 server422, which is a different partition than@pipelex/sdkmakes for the same argument on the same wire. Both SDKs make the same one.
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.
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.
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.
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, withis_terminal/is_successpredicates (exhaustivematch).RunRead— a run record read through the self-healing status path (addsdegraded,degradation,request_idandretry_after_seconds).degradation, aRunDegradationread leniently asLenientRunDegradation, says whatdegradedmeans: itsreason, aDegradationReasonread 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), sodegradedalone does not mean the run is fine (run-results.md).request_idis the id of the request that started the run, to quote to support, andRunDetailcarries it too. Itserror, declared onRunPublic, is the run's own problem, the standard'sProblemread 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 owndomain,retryandnext_step. It is read leniently everywhere it is served (LenientProblem,pipelex_sdk/answer_reading.py), so anything but a problem document reads asNoneand a read never fails on it. One type everywhere —RunPublic.error,PipelineRun.error,RunHistoryItem.error,RunResultFailed.errorandRunFailedError.run_error, the last two reading the problem the results read's409nests asrun_error(errors.md). RunResults— result artifacts, every field walked onrun-results.md.main_stuff(the resolved main output content) is always present for a completed run: on the hosted path it is themain_stuff.jsonS3 artifact; on the bare-runner blocking path the SDK resolves it from the returned working memory via the response'smain_stuff_name, so both paths deliver the same shape. Consumers readmain_stuffdirectly. The executed graph (graph_spec, withgraph_assembly_errorbeside it), the three I/O artifacts (pipe_io_contracts,input_form,output_form, typed by import frommthds.protocolexactly as on the validate report, withpipe_io_artifacts_errorbeside them) and the usage pair read the same on both paths: the hosted path relays each as an artifact, the blocking path lifts each offpipe_output— unwrapping the runner'spipe_io_artifactsenvelope onto the three sibling fields.working_memoryreads the same way and is typed by import too, asmthds'sDictWorkingMemoryAbstractbeside theDictPipeOutputAbstractthat typespipe_output: the hosted path relays theworking_memory.jsonartifact as its own key, the blocking path liftspipe_output.working_memory, which the standard declares required and which is therefore always set there. A completed run that cannot deliver a main stuff raisesMissingMainStuffError. Extension-open, so any other server artifact is preserved; and a key the hosted body did not carry is absent frommodel_fields_set, which is how a Python reader tells "not relayed" from "relayed as null" where the JS twin readsundefined.TokensUsageRecord— one client-facing usage record per inference call, carried byRunResults.tokens_usageson 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. Seerun-usage.mdfor the field reference, the cost/null semantics, and the old-artifact rules.RunProgress— a run's progress snapshot, whichget_run_progressreads andWaitForResultOptions.on_progressreceives: itsstatus(RunProgressStatus, read open, withis_ended), itsversionandassembled_at, a one-linesummary, the status read'sdegradation, and every invocation of a pipe in the run as aProgressInvocationnamed by its path, with its status (ProgressInvocationStatus, read open), its times, a batch's counts (ProgressBatch), a condition's outcome (ProgressCondition) and the health annotationsattempts,last_failure(ProgressFailure, whoseproblemis the standard'sProblemin the caller's view) anddegradation, the last reusingRunDegradationread asLenientRunDegradation.chainis a list ofProgressAttempt. 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 onstate(running/completed/failed).UsageSummary,PipeUsageSummary,UsageTokenTotalsandUsageSummaryState(pipelex_sdk/usage.py) — the run-level fold of the usage pair thatsummarize_usage(results)returns, the twin of@pipelex/sdk'ssummarizeUsagedown 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 onrun-usage.md, including the one divergence from the JS twin: a results body that never carriedtokens_usagesraisesFieldNotIncludedErrorinstead of reading asunavailable, sincemodel_fields_settells an absent key from a relayednullwhere TypeScript cannot.WaitForResultOptions/PollInfo— poll-loop tuning, theon_pollcallback with itsPollInfo, and theon_progresscallback, which receives each fresher progress snapshot. Async-native cancellation is viaasyncio.CancelledError(cancel the awaiting task), so there is nosignalfield.
get_run_status(run_id)—GET /v1/runs/{id}/status→RunRead. Lifts theRetry-Afterheader ontoretry_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 aRunFailedError.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-absent404raisesRunLifecycleUnavailableError, worded for a bare runner or a hosted API older than the route; the platform's own404of a run that does not exist is anApiResponseError, 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, withartifacts(a sequence ofRunArtifact) sent as one comma-separated?artifacts=parameter that narrows the200body to the named artifacts; an unselected artifact is absent from the result, whichRunResults.carries(artifact)reports, and theMissingMainStuffErrorcheck runs only whenmain_stuffwas asked for (run-results.md). It maps the platform's poll semantics to theRunResultStateunion:202/503→running(in-flight / degraded — never fail a poller),200→completed,409→failed. The409is a problem document:run.failedis the failed arm, the document whole as itsproblem, itsdetailasmessage, the terminal status itsrun_statusparameter names asstatus, and the run's own problem, which it nests asrun_error, aserror;run.not_finishedis the running arm, waiting itsretry_after. The status is read from that parameter and never parsed out of the sentence; arun.failednaming no status this SDK knows reads asFAILED. The nested problem is read leniently, so a problem written by another version that does not fit reads asNone—messagestill carries the reason either way. Any other409stays anApiResponseError. The status read and the run lists read the run's problem the same way (LenientProblem).wait_for_result(run_id, options, *, artifacts=None)— pollsget_run_resultwith the same selection to a terminal state, honoringRetry-Afterand the deadline. Resolves onCOMPLETED; raisesRunFailedErroron any other terminal status, carrying the failed arm'sstatus,messageanderror, andRunTimeoutErrorif the budget elapses (the run keeps executing server-side — resume later by id). Each poll is bounded by the budget left withasync with asyncio.timeout(remaining), and a poll the budget cuts off is that sameRunTimeoutError.asyncio.wait_foris 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.timeoutdelivers it on every supported version, so cancelling the task (anasyncio.timeoutaround the wait, a framework cancelling a request handler, Ctrl-C underasyncio.run) always raisesasyncio.CancelledErrorout of the loop. Withon_progress, each poll that finds the run running also readsget_run_progress, after the results read and beforeon_poll, and delivers each snapshot fresher than the last byis_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-absent404stops the progress reads for the rest of the wait. It is bounded like the results read: the deadline is checked again before it,asyncio.timeoutbounds it by the time left, a read the budget cuts off is the wait'sRunTimeoutError, 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 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/versionhandshake (_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/sdkfollows too, is that an answer that is not a usable version assumes hosted and letsstartsurface 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_readreports as theApiResponseErrorof that answer read with an empty body (_undecodable_answer), its cause httpx'sDecodingError. A handshake that gets no answer raises itsApiUnreachableErrorat once and caches nothing, so no start is sent and the next call asks again. The handshake and a plainstarttakerequest_timeout_secondscapped at the poll budget, and astartcarrying the method's tree, a bundle ridingextra(mthds_contentsorbundle_b64) or amethod_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 nativepipe_output, mapped ontoRunResults— the SDK resolvesmain_stuffout of the working memory via the response'smain_stuff_nameand keeps the full working memory onpipe_output. - Self-heal: a runner can look hosted yet lack the durable routes (
implementationis an optional extension a compliant bare runner may omit). The client'sstartoverride catches theApiResponseErrorthe inherited route raises and translates a bare-runner missing-route404intoRunLifecycleUnavailableError— raised before any run is created — sostart_and_waitfalls 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; amethod_refwhose package does not exist, which the platform relays from the runner unchanged) and stays anApiResponseError, so a refused start never demotes the client to the blocking path.
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.
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
filesmap, given asfiles,treeorcontentsas on the runs, its diagnostics naming each file by tree path),method_ref=(runner-resolved by address through the same fetch path as amethod_refrun, the package's real file names feeding the diagnostics' source labels), ormethod_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 raisesRequestArgumentErrorclient-side, mirroring the server's request-shape422. The hosted platform validates a tree only for a caller acting in an organization, and answers400otherwise. A selector-resolution failure (a fetch failure, no package at the address, an unknown or foreign-org id) is a non-2xx — never anis_valid: falseverdict, 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_validatebody-building seam, which refuses an empty map and sendsrenderandviewsthrough the protocol'sextrapassthrough; a selector validation must NOT carry thefileskey at all (the server XORs on presence, and an empty map is a request-shape422), so its body is built in the override and sent on the same inherited_sendtransport seam. Both paths get the markdown render injection and both parse intoPipelexValidationResultAdapter. -
Markdown render is always injected.
validate(...)adds"markdown"to therenderlist (de-duplicated, caller tokens first) so both a validPipelexValidationReportand a producedPipelexInvalidReportcarryrendered_markdown. Unknown render tokens are server-side lenient-ignored. -
viewsis the opt-in for the server's structured views. The tokens are named by constants —VALIDATION_VIEW_INPUT_FORMforinput_formandVALIDATION_VIEW_OUTPUT_FORMforoutput_form, the descriptor of what the pipe produces. Unlikerender, 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 a422), so client-side normalization would only hide what the caller asked for. Left atNonethe 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).
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]frommthds.protocol.pipe_io_contracts. An input slot reads as typed members:concept_ref, a three-valuedpresence(PresenceMarker, so an authored!is not flattened into a boolean), amultiplicity(IOMultiplicity), theitem_countthat is non-null exactly on the fixed arm, and the slot'sjson_schema. The output side stays asymmetric in exactly one place: a two-valuedoptional, because!is a use-site assertion about an input and is rejected on an output. It is no longer asymmetric on the schema — sincemthds0.13.0 an output carries a requiredjson_schematoo, 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]frommthds.protocol.input_form. A descriptor'sfieldsare the recursiveInputFormFieldunion discriminated onkind; narrow a node withmatch node: case ListField(): …or anisinstancecheck, importing the per-kind models frommthds.protocol.input_form. Anobjectnode recurses throughfields, alistnode throughitem— and the item changes layer. Sincemthdsv0.10.0 the union is split by whether a node names itself: a top-level field is the named union (TextField,DocumentField, …, each requiringname: str), aListField.itemis the nameless one (TextItem,DocumentItem, …), which refuses anameat the parse. Narrow a list's item toDocumentItem, neverDocumentField; because each*Fieldderives from its*Item, the item layer is the only safe narrowing target in that position.output_form: OutputForm | None—dict[pipe_ref, PipeOutputFormDescriptor]frommthds.protocol.output_form, the twin of the above on the other side of the pipe. It carries a singlefieldrather than afieldslist, since a pipe has exactly one output, and reuses the sameInputFormFieldnode union verbatim rather than declaring a second one that could drift — so a renderer walks it with the patterns it already has. It states nopresenceand nogating: those are facts of a slot a caller fills, and a result is not one; plurality is stated by wrapping the field in alistnode. Read it together with that pipe'soutput.json_schemaoffpipe_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.
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.
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() 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_filenameother thancodegen.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 raisesCodegenErrorand writes nothing. The lock and prune checks go further thanpipelex: 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, aspipelexskips 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
pipelexalso requires the rest of the header to parse, so a file whose stamp header was edited is overwritten by this writer and refused bypipelex. - 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.
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.
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 absentinput_datais 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, whichwrite_draft(PUT /v1/methods/{id}/draft) replaces without validating it: the whole tree, given asfiles,treeorcontents, sent inline or by hash after uploading the files the organization lacks (method-trees.md).get_methodanswers aMethodDetail, the method withfiles, its draft's tree listed with a short-lived link to each file's bytes, whichwrite_method_treebrings to disk; the creation, the draft write, the rename and the publish answer aMethodData, which lists no files.MethodData.updated_atis the draft's token, which moves on every draft write and nothing else, anddraft_digestis the draft's tree digest,sha256:and 64 hex digits, orNonefor an empty draft. The draft body always carriesfiles, and carriesinput_dataonly when the caller set it, which is what gives it its keep, clear and replace meanings, and it may carryexpected_updated_at, the token the caller last saw: the write is then a compare-and-swap, and a draft that moved since is a409pipelex.catalog.update_conflictwith 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-onlyexpected_draft_updated_at, refusing one that is not astrwith aRequestArgumentErrorbefore anything is sent, and answers aMethodPublishResult, a discriminated union onoutcomeparsed byMethodPublishResultAdapter:MethodPublishedwith the new version,MethodPublishUnchangedwith the existing latest version when the draft's digest equals it, andMethodPublishRefusedwith areason(invalidornot_runnable), amessageand the runner'svalidationverdict, typed asPipelexValidationResultand read member by member asvalidatereads it. Every arm carries themethod, and a body naming no known outcome raises theApiResponseErrorof 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 a502or503with nothing written, so a retry is safe, and a retry of a publish that landed while its answer was lost answersunchangedwith 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 a413request.too_largewhen it would grow the method past that room, below the store's item limit, and a rename is the same413for 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 itstree_digest, itscrate_fingerprint(Nonewhen the runner could not resolve the bundle in memory), the runner's version, and when and by whom it was published.list_method_versionspages the summaries newest first (MethodVersionPage, alimitfrom 1 to 100, 20 by default, and a422outside that range), andget_method_versionreads one as aMethodVersion, with itsfileslisted asget_methodlists the draft's; a version that is not a positive integer is refused withRequestArgumentErrorbefore 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, somt_x@3would answer a404that reads as a method that does not exist: each route refuses a suffixed id withRequestArgumentErrorbefore anything is sent, namingparse_method_selectorandget_method_version. A method read carrieslatest_versionandlatest_published, bothNoneuntil the first publish, so the publish state is derived: never published, draft unchanged (draft_digestis notNoneand equalslatest_published.tree_digest), or draft ahead. A version summary'sversionand a method'slatest_versionare 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-methodupdate_methodis gone, replaced by the draft write and the rename.MethodErrorCode(pipelex_sdk.errors) names the codes a method caller branches on inproblem.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_foundandpipelex.catalog.copy_too_large.The index is paged.
list_methods(q=…, published=…, limit=…, cursor=…)returns oneMethodPage—items: list[MethodSummary]plus an opaquenext_cursoryou pass straight back,Noneon the last page, and no total by design.next_cursoris required on every page model (MethodPage,MethodVersionPage,MethodExamplePage,MethodCopyPage,RunPage), as@pipelex/sdkrequires 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. AMethodSummaryis deliberately not aMethodData: noupdated_at, which is not in the index projection, and no files, which onlyget_methodlists; 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…" — whileget_methodrefuses it with a409. Query parameters are kept on presence, never truthiness: an explicit emptyqorcursoris 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=Truesendspublished=trueand keeps the methods with a published version,published=Falsesendspublished=falseand keeps those never published, andNonesends nothing and lists both; it combines withq. EachMethodSummarycarrieslatest_versionandlatest_published(aMethodVersionSummary), bothNonewhen the method was never published, anddraft_digest,Nonefor an empty draft. All three default toNone, so a platform that does not serve them still answers a row the SDK reads. Never published islatest_version is None, the draft unchanged is adraft_digestthat is notNoneand equalslatest_published.tree_digest, and the draft ahead is two digests that differ. Bothqandpublishedfilter after the index read, so a filtered page can come back empty and still carry anext_cursor: an empty page is not the end of the list, and only aNonecursor is.iterate_methods(q=…, published=…, limit=…)is the idiom for the whole catalog, and there is deliberately nolist_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, becauseqandpublishedare post-read filters over a bounded index slice per request, so{items: [], next_cursor: "…"}means "keep going". It stops on aNonecursor, 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 raisesPagingNotTerminatingErrorrather than returning what it has, because a silently truncated list is the bug being fixed.delete_methodis asynchronous and its return type says so: the route answers202with aMethodDeletionAccepted(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 fromlist_methods, not any field of that body. It used to be annotated-> Nonewith 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 a409pipelex.catalog.being_deletedrather 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 aninput_revisionthat rises each time its inputs change and thelatest_run_idof those inputs, which an inputs edit clears.record_method_example_latest_runrecords a run against the revision whose inputs it used and answers aMethodExampleLatestRunResultdiscriminated onoutcome,MethodExampleLatestRunRecordedorMethodExampleLatestRunStale, each carrying theexample: 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_assistantmarks the example the build assistant keeps current with its test inputs, until a person's first inputs edit makes it theirs, andsave_assistant_method_exampleis the assistant's save, aMethodExampleAssistantSaveResultdiscriminated onoutcome,MethodExampleAssistantKeptwhen the named example still held the revision read andMethodExampleAssistantCreatedotherwise, 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 whilenext_cursoris set;iterate_method_examplesfollows it, and refuses a method id at the call rather than at the first__anext__.MethodExamplereads its fields strictly (StrictStr,StrictIntwithin 2^53 - 1 either way,StrictBool), as@pipelex/sdkchecks 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 whosenameorinputsisNoneor whoserun_idis given but empty, an edit naming neithernamenorinputs, a latest-run write without a run id or a positive integer revision, and an assistant's save naming only one ofexample_idandinput_revisionbefore 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}/copyand/v1/methods/{id}/copies.copy_methodmakes 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 aMethodCopyResultdiscriminated onoutcome,MethodCopiedwith the copy asmethodand what was copied, orMethodCopyRefusedwith thereferencesof 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;MethodCopyResultAdapterreads it, asMethodPublishResultAdapterreads a publish, and an answer off the union is one the SDK cannot read. Its body is closed,destination_org_idwithnameandexpected_draft_updated_atwhen given, and itsidempotency_keyis sent as theIdempotency-Keyheader through the same context variable_sendreads for a start, set around the copy's one request and checked by the same_idempotency_key_of.list_method_copiesanswers oneMethodCopyPageofMethodCopyMarkerrows, aslist_method_versionsdoes, with no iterator. Every method read carriescopied_from, aMethodCopiedFromnaming the original on a copy andNoneotherwise.MethodErrorCodenames the copy's own codes,ORGANIZATION_NOT_FOUNDandCOPY_TOO_LARGE, whoselimitmemberMethodCopyLimitreads. 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'srole_in_orgis anOrgRoleor the raw string of a role this SDK does not know, so amatchover 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 raisesApiResponseError. Organization switch is out of scope (a WorkOS session op, not a/v1route). -
Organization branding —
get_branding()(GET /v1/branding, any role, web session or API key) andupdate_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 explicitNoneclearing one; a refused logo or colour is named inApiResponseError.errors, by slot or by dotted key, a logo the platform measured with aLogoRefusalCode. See organization-branding.md. -
Caller webhooks —
list_run_webhook_deliveries(run_id),get_run_webhook_delivery(run_id, delivery_id)andreplay_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), onGET /v1/organizations/{org_id}/webhook-secret, androtate_webhook_secret(org_id, idempotency_key=…), onPOST /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, therun.finishedevent's models andverify_webhook, which needs no client, live inpipelex_sdk/webhooks.py. Seewebhooks.md. -
Billing —
get_subscription(),list_plans(),list_invoices(),create_checkout(plan).change_plan(plan)andget_billing_portal()surface a 409conflict(ApiResponseError.code) when there is no subscription yet — start one viacreate_checkoutfirst. -
Pipelex API keys —
list_pipelex_api_keys();create_pipelex_api_key(label)androtate_pipelex_api_key(id)return the plaintextapi_keyonce;revoke_pipelex_api_key(id). Creation surfaces a 409pipelex_api_key_limit_reachedwhen 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 throughPOST /v1/resolve-storage-url/bulk, the route the artifact stack is built on;upload(UploadInput)→ the stored file handle,UploadedFile, with the file'suri, itsfilenameand thefile_idof its record, the input'skind(UploadKind) and, for a test file, itsmethod_idrecording it as a person's file or as a test input made for one method, sent only when given. The higher-levelupload_file/prepare_inputspreparation surface built on top ofuploadis now available — see input-preparation.md — and takes the same pair,upload_fileas itskindandmethod_idarguments andprepare_inputsas itsupload(UploadKindOptions), each checked before any file is read, a test file the upload route refuses for the caller's role or for its method raisingTestFileRefusedError(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) andcopy_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;FileSummaryis a list row andFileRecordthe whole record. The list's repeated filters are namedformatsandmedia_types, plural becauseformatis a Python builtin, and sent under the API's singular names; an empty one raisesRequestArgumentErrorbefore any request, since it would be sent as no parameter and list everything, and a barestrraisesTypeError._repeated_filtercopies the values once before checking them, so a generator is neither exhausted by the check nor dropped, anditerate_fileschecks its filters at the call and sends those copies with every page through_follow_cursor, as the other iterators do.link_filestakesFileLinkTargets, a frozen closed model checked at construction to name exactly one offile_idanduri, and raisesTypeErrorfor an item of any other type, a plaindictincluded, and answers oneFileLinkedorFileLinkRefusedper requested file, whichFileLinksResultchecks against the request's count through the validation context. An archive is queued and then read untilreadyorfailed, with no waiting helper; a selection past a cap is a422file_archive_too_largewhose problem document'slimitFileArchiveLimitreads. The copy into a draft answers aDraftFileFromFileResultdiscriminated onblob. 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'sstate, a run's files'stateand a copy'sblob, are read closed, as@pipelex/sdkchecks 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 aRunPageofRunHistoryItemrows with the same opaque-cursor contract asMethodPage, every argument keyword-only and optional. The method is one filter among the others:method_idlists 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 samemethod_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, andmethod_versionandsource_digest, which version of the method the run ran (a number,"draft", orNonefor an inline source) and the SHA-256 of the sources it sent as text. A row also carriesresults_stored, which says whetherget_run_resultcan read the run's results:Trueexactly when the run isCOMPLETEDwith its results stored, andFalseon every other row. Only while the run is still queued or running (PENDING,STARTED,RUNNING) doesFalsemean "not yet". On a run that ended any other way thanCOMPLETEDit isFalsefor good, there being no results to read, and on aCOMPLETEDrun it isFalsefor good when the result went inline to the caller that started it, as a blockingexecuterun's does, and the results read of such a run answers404 run.results_not_stored, so a history asks for no results on that row. It defaults toNone, which a row from a platform that predates the field reads as, and which says nothing either way: it does not meanFalse. It is read strictly, asmethod_versionis: a value that is not a boolean, such as the string"true"or the number1, 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_versionandsource_digestdefault toNoneon every run record (RunPublic,RunRead,PipelineRun,RunDetail,RunHistoryItem), since a run recorded before the platform recorded them carries neither. On every run record,method_versionis 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_detailserves the whole record when a run is opened.created_from/created_toare 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 asrequest.invalid, surfaced asApiResponseError.created_by="me"(typedLiteral["me"]) sendscreated_by=meand keeps the runs the caller started, which the platform resolves from the authenticated identity; it takes no other value.status, any iterable ofRunStatus, read once so that a generator works too, is sent as one repeatedstatus=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 iscreated_by="me",status=[RunStatus.PENDING, RunStatus.STARTED, RunStatus.RUNNING],limit=1. An emptystatuswould be sent as no parameter at all, which lists every status, so it raisesRequestArgumentErrorbefore any request, as does a name that is noRunStatus; a barestrraisesTypeError, 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 thanlimit, or none, and still carry anext_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 theff_api_keysfeature flag and fails closed with a403— so a403here means "flag", not "wrong key".The iterators run one loop.
iterate_methods,iterate_runsanditerate_method_examplesall 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 aNonecursor, and stops when the server hands back the cursor it was just sent, checked before yielding. They share the same runaway page ceiling and raisePagingNotTerminatingErrorat 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; thecreated_byandstatusfilters made that wrong, since a filtered run page can be empty and still not be the end.iterate_runschecks itsstatusat the call, asiterate_fileschecks 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_idandcreated_by_user_idare 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_codeisNonefor a run that let the bundle'smain_pipedecide, anderrorisNoneon every run that did not fail. OnPipelineRun, the whole record and the base ofRunDetail,method_idisNonefor an ad-hoc run from an inline bundle, which belongs to no stored method;pipe_codeisNonefor a run that let the bundle'smain_pipedecide.org_id,created_by_user_id, anderror, the run's own problem orNone, read exactly as on the status read, join them.RunDetail, returned only byget_run_detail, addsmthds_contentsandinputs: 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 carriestree_digest, the digest of the method tree the run sent asfiles, andcrate_fingerprint, the crate the runner loaded it into, eachNonefor a run that recorded none; a run'ssource_digestis not a tree digest but the SHA-256 of the sources it sent as text, 64 hex digits with no prefix, andNonefor 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.
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() → 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 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.
- The
/v1/build/*helpers. Neither SDK carries them any more. This one never shippedbuild_output,build_runner,conceptorpipe_spec, and removedbuild_inputs, which shipped in 0.5.0, onceprepare_inputs, its only caller, moved onto the input-form descriptor (read fromvalidatethen, frompipe_ionow).@pipelex/sdkretired 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 descriptorpipe_ioreturns, withmthds.protocol.inputs_template. - Organization switch (a WorkOS session operation, not a
/v1route). - A reader of
~/.pipelex/.env(or the.envunderPIPELEX_HOME), the filepipelex loginsaves the key to. The client reads its arguments and the process environment only, as the JS SDK does, and thepipelex-sdkcommand reads no.envfile on purpose (cli.md, "The environment"), so loading that file is the caller's step ("Credentials & configuration" above). - A synchronous client facade.
__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).
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(resolveandcodegenshipped in 0.8.0 with the method selectors and are not gaps). - The upload grant —
@pipelex/sdk'srequestUploadGrant(POST /v1/upload/grant),describeUploadanduploadWithGrantlet 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 throughPOST /v1/upload, where the platform hashes the bytes itself and answers the file'sfile_id. WhatuploadWithGrantdecides from storage's answers therefore has nothing to apply to here, the412of a create-onlyPUTread as stored for a grant that names its bytes among them; the sharedRejectedAssetCodeandUploadTransportCodestill 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/executegateway-timeout translation (Phase-1 flag) — implemented (see "executeoverride" above), closing the one genuine feature gap rather than deferring it.healtherror regime (decision #5) — unified: a non-2xxhealthanswer raisesApiResponseError, as@pipelex/sdk'shealthdoes, so its verdict is decided like every refusal's.- Protocol-route error regime (the Phase-3
validateflag, and decision #5's inherited-protocol vs product split) — unified: every/v1route raisesApiResponseError,mthds's own error narrowed, as every@pipelex/sdkroute 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.