Pipe Run
Execute pipelines with flexible input formats. Choose between synchronous execution (wait for completion) or asynchronous execution (non-blocking). Both routes are part of the MTHDS Protocol — this server is the protocol's reference implementation.
About
mthds_contentsand MTHDS. An "MTHDS file" is a Pipelex pipeline definition written in TOML (with the.mthdsextension). Themthds_contentsfield on every endpoint below is a JSON array of those file contents as raw strings — typically[open("my_pipe.mthds").read()]from a client. You can either pass apipe_codereferring to a pipe already registered on the server, passmthds_contentsinline (the array must contain a file with amain_pipeproperty), or both.
Execute Pipeline
Execute a Pipelex pipeline with flexible inputs and wait for completion.
Endpoint: POST /v1/execute
Backend selected by
orchestration_mode./executedispatches through the deployment'sorchestration_mode(config default + optional policy-gated per-requestorchestration_modeoverride), symmetric with/start— see Configuration → Orchestration mode. On the orchestrator-agnostic base (direct, the default) it runs in-process; atemporalflavor dispatches the run to a worker and awaits it./executeis synchronous (it returns the full output) and always usesBLOCKINGdelivery — wait-semantics is endpoint-set, never requestable, so there is no fire-and-forget option here (usePOST /v1/start). A per-request backend override is honored only where the deployment setsallow_request_orchestration_mode_override = true; otherwise a token differing from the default is refused with a403.
Request Body:
{
"pipe_code": "your_pipeline_code",
"mthds_contents": null,
"inputs": {
"input_name": "simple text or object"
},
"output_name": null,
"output_multiplicity": null,
"dynamic_output_concept_ref": null
}
Request Fields:
pipe_code(string, optional): The code identifying the pipeline to execute. Required if nomthds_contentsis provided, or ifmthds_contentsdoesn't have amain_pipeproperty. Can be combined withmthds_contentsto specify which pipe to execute from the provided MTHDS definition.mthds_contents(list[str], optional): Inline MTHDS file contents as raw strings (always an array, even for a single file). If provided withoutpipe_code, the bundle must contain a file with amain_pipeproperty.inputs(PipelineInputs, optional): Flexible input format - see Input Format: PipelineInputs belowoutput_name(string, optional): Name for the output slotoutput_multiplicity(string, optional): Output multiplicity setting ("single","variable", or a specific number)dynamic_output_concept_ref(string, optional): Override output concept reffiles(object, optional): The method as a map of file entries, the MTHDS standard's method tree: each key a tree path, each value the file's text,{"base64": "…"}, or{"sha256": "…", "size": n}naming bytes this runner holds — see Sending a method as a tree. Mutually exclusive withmthds_contents,bundle_b64andmethod_ref.dependencies(object, optional): Pipelex-API extension. The method packages the method calls by address that the runner cannot fetch, each a map of file entries keyed by its address — see Sending the method packages the method calls. Accompaniesfiles.bundle_b64(string, optional): Pipelex-API extension. Base64-encoded zip of the method's folder, read as a map of its entries, which may carry the method packages it calls under.mthds/methods/<name>/. Mutually exclusive withfiles.method_ref(string, optional): Pipelex-API extension. Run a published method by address instead of inline source — see Running a method by address. Mutually exclusive withmthds_contentsand with a method sent as a tree;pipe_codemay accompany it to override the package'smain_pipe.resources(dict[str, str], optional): Pipelex-API extension. The files the bundle's steps read, such as aPipeDocGenstep'stemplate_file, as a map from the path the step names to the file's bytes in standard base64 — see Sending the files the steps read. It accompaniesmthds_contentsor a method sent as a tree, whose tree each file joins at its path from the root; besidemethod_ref, or with no bundle at all, it is a 422. The job names the method's tree by its digest and carries none of its files: the step that reads one, on an orchestrator that runs the job remotely such as Temporal included, reads it through the deployment's method file store.storage_scope(string, optional): Pipelex-API extension. The prefix every object this run writes lands under — see Host-supplied run context.read_scope(string, optional): Pipelex-API extension. The prefix every stored object this run reads must lie under — see Host-supplied run context.analytics_groups(dict[str, str], optional): Pipelex-API extension. The groups this run's telemetry belongs to, such as{"organization": "org_acme"}— see Host-supplied run context.
Validation Rules:
- At least one of
pipe_code,mthds_contents, a method bundle, ormethod_refmust be provided - If only
pipe_code: Must reference a pipe already registered in the library - If only
mthds_contents: Must contain amain_pipeproperty - If both:
pipe_codespecifies which pipe to execute from themthds_contents
Response:
{
"pipeline_run_id": "abc123...",
"created_at": "2026-01-15T12:00:00Z",
"state": "COMPLETED",
"finished_at": "2026-01-15T12:00:05Z",
"main_stuff_name": "result",
"crate_fingerprint": "9f2c41…",
"pipe_output": {
"working_memory": {
"root": { "result": { "content": { "...": "..." } } },
"aliases": { "main_stuff": "result" }
}
}
}
Response Fields:
pipeline_run_id(string): Unique identifier for the run.created_at(string): ISO timestamp when the pipeline was created.state(string): One of"RUNNING","COMPLETED","FAILED","CANCELLED","ERROR","STARTED".finished_at(string | null): ISO timestamp when the pipeline finished, ornullif still running.main_stuff_name(string | null): Key underpipe_output.working_memory.rootwhere the main result lives. Use this to extract the typed output:pipe_output.working_memory.root[main_stuff_name].content.crate_fingerprint(string | null): Pipelex extension. The fingerprint of the library crate the run's method was loaded into, the identity of its concepts and pipes, which changes with their meaning and not with their formatting or comments; a host records it on its run beside the digest of the method tree that ran.nullwhen the run was built from no crate.pipe_output(object): Result of the pipeline execution. Containsworking_memorywithroot(every named stuff produced during the run) andaliases(built-in name mappings such asmain_stuff).pipe_output.tokens_usages(array | null): Per-inference-call token usage in the client wire shape (TokensUsageRecord):model_type, model name/id,pipe_code, job-kind fields,nb_tokens_by_category, computed USDcost(nullwhen the model has no rate table), and ISO timestamps.nullwhen usage assembly was off for the run,[]when no inference happened.pipe_output.usage_assembly_error(string | null) is non-null when usage assembly failed. See the pipelex runtime's TokensUsage Wire Records for the full field reference.pipe_output.graph_spec(object | null): Best-effort execution graph of the run — the nodes and edges the run actually traversed.nullwhen graph generation was off for the run, which is theinterpreter.pipeline_execution.is_generate_graphsetting (truein the packaged config); it does not depend onruntime.tracing, whoseis_enabledselects a trace-emission backend rather than gating assembly.pipe_output.graph_assembly_error(string | null) is non-null when assembly ran and failed, which is what distinguishes a failed assembly from generation having been off.pipe_output.pipe_io_artifacts(object | null): The I/O artifacts describing the graph's data, carried besidegraph_specso that wherever the graph travels its description travels with it. It groups the same artifacts/validateproduces —pipe_io_contracts,input_formandoutput_form— each keyed by namespacedpipe_ref, over the run library's own pipes: a dependency package's pipes are keyed by alias in the library but by barepipe_refin the artifacts, where they would collide with the host's, so they are left out and a graph node contributed by a dependency has no entry here. Holding it with the graph is what lets a viewer show a data node's value rather than only its shape. It is built only when the run generated a graph andinterpreter.pipeline_execution.graph.graphs_inclusion.graphspec_jsonis on (both default totrue), and only whengraph_specitself came out non-null — so a run withgraphspec_json = falsereturns a graph with no artifacts beside it and no error, which is the one case where the two fields disagree.pipe_output.pipe_io_artifacts_error(string | null) is non-null when the build ran and failed. See Pipe Validate for the artifacts' own field reference.method_provenance(object, only onmethod_refruns):{address, tag, commit_sha}— the package's resolved full address, the requested tag (nullfor a bare address), and the commit SHA that was actually fetched. Absent for runs from inline source or a bundle. See Running a method by address.
Errors are returned as RFC 7807 application/problem+json bodies with HTTP 4xx/5xx status codes. The successful response body has no status/error field — the HTTP status code is the source of truth. An invalid bundle is refused before any pipe runs with a 422 carrying the same located validation_errors validating it gives (Structured validation errors), and a run that fails reports its root fault at the pipe that failed (Run failures).
Start Pipeline
Start a pipeline execution and get its pipeline_run_id back with a 202 ack.
Endpoint: POST /v1/start
Fire-and-forget is a property of this endpoint, honored only by an async-capable backend.
orchestration_modenames only the deployment's backend;/startsetsFIRE_AND_FORGETdelivery and requires an orchestrator that can honor it. A Temporal deployment (orchestration_mode = "temporal") enqueues the run and returns immediately with aworkflow_id. On the orchestrator-agnostic base (orchestration_mode = "direct", the default — see Configuration → Orchestration mode) the in-process orchestrator is blocking-only, so/startis HONEST: it refuses with a400(StartRequiresAsyncOrchestration) — usePOST /v1/execute— rather than silently running blocking and acking. The completion callback fires on the async path.
Request Body:
{
"pipe_code": "your_pipeline_code",
"mthds_contents": null,
"inputs": {
"input_name": "simple text or object"
},
"output_name": null,
"output_multiplicity": null,
"dynamic_output_concept_ref": null
}
Request Fields:
pipe_code(string, optional): The code identifying the pipeline to execute. Required if nomthds_contentsis provided, or ifmthds_contentsdoesn't have amain_pipeproperty.mthds_contents(list[str], optional): Inline pipeline definitions (always an array, even for a single file). If provided withoutpipe_code, must have amain_pipeproperty.inputs(PipelineInputs, optional): Flexible input format - see Input Format: PipelineInputs belowoutput_name(string, optional): Name for the output slotoutput_multiplicity(string, optional): Output multiplicity setting ("single","variable", or a specific number)dynamic_output_concept_ref(string, optional): Override output concept reffiles(object, optional): The method as a map of file entries, the MTHDS standard's method tree: each key a tree path, each value the file's text,{"base64": "…"}, or{"sha256": "…", "size": n}naming bytes this runner holds — see Sending a method as a tree. Mutually exclusive withmthds_contents,bundle_b64andmethod_ref.dependencies(object, optional): Pipelex-API extension. The method packages the method calls by address that the runner cannot fetch, each a map of file entries keyed by its address — see Sending the method packages the method calls. Accompaniesfiles.bundle_b64(string, optional): Pipelex-API extension. Base64-encoded zip of the method's folder, read as a map of its entries, which may carry the method packages it calls under.mthds/methods/<name>/. Mutually exclusive withfiles.method_ref(string, optional): Pipelex-API extension. Run a published method by address instead of inline source — see Running a method by address. Mutually exclusive withmthds_contentsand with a method sent as a tree;pipe_codemay accompany it to override the package'smain_pipe.resources(dict[str, str], optional): Pipelex-API extension. The files the bundle's steps read, such as aPipeDocGenstep'stemplate_file, as a map from the path the step names to the file's bytes in standard base64 — see Sending the files the steps read. It accompaniesmthds_contentsor a method sent as a tree, whose tree each file joins at its path from the root; besidemethod_ref, or with no bundle at all, it is a 422. The job names the method's tree by its digest and carries none of its files: the step that reads one, on an orchestrator that runs the job remotely such as Temporal included, reads it through the deployment's method file store.storage_scope(string, optional): Pipelex-API extension. The prefix every object this run writes lands under — see Host-supplied run context.read_scope(string, optional): Pipelex-API extension. The prefix every stored object this run reads must lie under — see Host-supplied run context.analytics_groups(dict[str, str], optional): Pipelex-API extension. The groups this run's telemetry belongs to, such as{"organization": "org_acme"}— see Host-supplied run context.
Validation Rules:
- At least one of
pipe_code,mthds_contents, a method bundle, ormethod_refmust be provided - If only
pipe_code: Must reference a pipe already registered in the library - If only
mthds_contents: Must contain amain_pipeproperty - If both:
pipe_codespecifies which pipe to execute from themthds_contents
Important Notes:
- This endpoint answers
202 Acceptedimmediately with aStartAckcarrying thepipeline_run_id - The pipeline continues executing in the background
pipe_outputwill benullin the response (pipeline hasn't completed yet)- The request body MAY carry a client-supplied
pipeline_run_id(max 128 chars): this server honors it, and theStartAck.pipeline_run_idechoes it back. When absent, the server generates one. (StartAck.pipeline_run_idis always authoritative — protocol rule.)
Response (202):
{
"pipeline_run_id": "abc123...",
"created_at": "2026-01-15T12:00:00Z",
"state": "STARTED",
"finished_at": null,
"main_stuff_name": null,
"pipe_output": null,
"workflow_id": "pipeline-abc123...",
"crate_fingerprint": "9f2c41…"
}
Response Fields:
pipeline_run_id(string): Unique identifier for the run. Use this to correlate callbacks (see below).created_at(string): ISO timestamp when the pipeline was started.state(string): Always"STARTED"from this endpoint — the pipeline is queued, not finished.finished_at(null): Alwaysnull; the pipeline hasn't completed.main_stuff_name(null): Alwaysnull; populated only on the eventual completion callback.pipe_output(null): Alwaysnull; the result isn't ready yet.workflow_id(string | null): The async orchestrator's workflow ID. A202ack is only ever returned by an async-capable backend (e.g. a Temporal flavor), so this carries that orchestrator's id; the in-processdirectbase never acks here — it returns a400(StartRequiresAsyncOrchestration) instead.method_provenance(object | null):{address, tag, commit_sha}for amethod_refrun — see Running a method by address;nullfor runs from inline source or a bundle.crate_fingerprint(string | null): Pipelex extension. The fingerprint of the library crate the run's job was built from, as on the/executeresult, known as soon as the run is queued, so a host records it from the acknowledgement.
Errors follow the same convention as /execute: HTTP 4xx/5xx with an RFC 7807 application/problem+json body.
Async Completion Callbacks (optional)
POST /v1/start accepts an additional optional field in the request body, callback_urls — a list of HTTP(S) endpoints the server will POST to once the pipeline finishes. This is a pipelex-api extension, not part of the MTHDS Protocol (the protocol defines no completion channel; each implementation defines and documents its own extension args). SDK clients pass it through the generic extra mapping, e.g. client.start(..., extra={"callback_urls": [...]}).
{
"pipe_code": "your_pipeline_code",
"inputs": { "input_name": "..." },
"callback_urls": ["https://my-app.example.com/pipelex-finished"]
}
When the pipeline completes, each URL in the list receives a POST carrying:
- A completion notice in the body, not the run's output:
pipeline_run_id, the deliverystate("COMPLETED"or"FAILED", also sent under the legacystatuskey),result_url(when results were stored: the storage key prefix under which the result files sit, not a fetchable URL) anderror(the rawErrorReportdict on failure). The receiver reads the results from storage under that prefix. - An
X-Completion-Signatureheader —HMAC-SHA256(secret, pipeline_run_id)rendered as a hex digest
Verifying the signature on the receiver side
Your callback handler should recompute the same HMAC using its own copy of the shared secret and reject any request that doesn't match. Pseudocode:
import hmac, hashlib
expected = hmac.new(
SHARED_SECRET.encode("utf-8"),
pipeline_run_id.encode("utf-8"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-Completion-Signature"]):
return Response(status=401)
The signer (this server) and the verifier (your callback receiver) must share the same secret value. The secret never travels over the wire — only the per-run HMAC does — so even if a callback request is intercepted, the secret stays safe.
Server-side requirement
Set the COMPLETION_CALLBACK_SECRET environment variable on the API server only if you use callback_urls. The variable is read lazily — the server boots fine without it, and only requires it when actually signing a callback. If you call /start with callback_urls and the env var isn't set, you'll get a 500 with EnvVarNotFoundError: Environment variable 'COMPLETION_CALLBACK_SECRET' is required but not set.
The receiver-side secret must be the same value. In typical deployments both sides pull from a shared secrets store (AWS Secrets Manager, Vault, etc.).
Sending a method as a tree
mthds_contents carries only .mthds text. A method is more than its bundles: a custom PipeFunc's Python, a PipeDocGen step's template, a requirements.txt, a METHODS.toml. Both /execute and /start, and /validate, take the whole method as a method tree, the MTHDS standard's unit of a method, in one of two mutually exclusive forms:
files: a map of file entries, the standard's form. Each key is the file's tree path, and each value one of three entries: the file's text as a JSON string,{"base64": "…"}for bytes that are not text, or{"sha256": "…", "size": n}naming bytes this runner already holds in its blob store. One request may mix all three.bundle_b64: a base64-encoded zip of the method's folder, a Pipelex-API extension, read as a map of its entries.
{
"files": {
"main.mthds": "domain = \"demo\"\nmain_pipe = \"crunch\"\n\n[pipe.crunch]\ntype = \"PipeFunc\"\ndescription = \"Crunch numbers\"\ninputs = { data = \"Text\" }\noutput = \"Text\"\nfunction_name = \"pipe_func.crunch\"\n",
"pipe_func.py": "def crunch(working_memory):\n return \"...\"\n",
"templates/logo.png": { "base64": "iVBORw0KGgo…" },
"templates/invoice.docx": { "sha256": "3e9b68b71cc667168399f0bccfb64b2d16314cdc0ed78fac4819aba0dda121a0", "size": 18342 }
},
"inputs": { "data": "1,2,3" }
}
One rule for every path. The map is the tree: the runner reads it with the standard's reader and holds every key to the tree's one rule, whatever form the method came in. A key that is not a tree path (absolute, with a . or .. segment, an empty segment, a backslash, a colon), a key the membership rule keeps out of every tree (a dotfile or a file under a dot directory such as .mthds/, .git/ or .env, __pycache__/, node_modules/), one key given twice, two keys naming one file once normalized, or a file that is another entry's directory is a 422 InvalidMethodFiles whose tree_refusal member gives the standard's refusal code and the names it refuses; a malformed entry, such as a size written 13.0, is refused the same way. A zip's member names are judged by the same rule, so a name given twice in an archive is a duplicate key.
The tree is written whole and loaded as the caller's. The runner writes the tree into a temporary directory, exactly as the map holds it, and loads it as one library directory that belongs to the caller: every bundle is a member of the tree and is named by its tree path, every other file is one a step may read where the bundle sits, a PipeDocGen template beside its bundle or a PipeFunc module at the tree's import root, and a refusal of the load is the caller's 422, or an is_valid: false verdict on /validate, naming files by their tree paths and never by the directory on the server. The directory is removed once the request returns, and the run's job names the tree by its digest, so an orchestrator that runs the job remotely reads its files from the deployment's method file store.
The entry pipe. With no pipe_code, the run starts at the pipe the MTHDS protocol's rule names: the main_pipe of the METHODS.toml at the tree's root, in the bundle of a domain exporting it, or, in a tree with no manifest, the one main_pipe its bundles declare. A manifest declaring none, or bundles declaring none or several, is a 422 MethodEntryError naming the bundles by their tree paths; name the pipe with pipe_code instead.
Limits. A tree is bounded before any of its bytes is read, on the sizes its entries state, and again once read: at most MAX_METHOD_TREE_FILES files (default 1,000), MAX_METHOD_TREE_FILE_KIB per file (default 32 MiB) and MAX_METHOD_TREE_TOTAL_KIB in all (default 64 MiB), each tree of a request counted on its own, and, across the method's tree and the dependency trees beside it, at most MAX_METHOD_DEPENDENCIES dependency trees (default 32) and MAX_METHOD_REQUEST_TOTAL_KIB in all (default 128 MiB), refused with a 422 MethodTreeTooLarge naming the limit. A bundle_b64 archive is also bounded as an archive: a member-count ceiling (MAX_BUNDLE_FILES) and a decompressed-size ceiling (MAX_BUNDLE_TOTAL_KIB), each a 413 PayloadTooLarge, its decompression bounded so a zip bomb cannot expand past the ceiling, and an oversized bundle_b64 refused on its encoded length before it is decoded. Supplying both bundle_b64 and files, an empty archive or a corrupt zip is a 422 InvalidBundle, invalid base64 a 400 InvalidBase64, and a tree holding no .mthds bundle of its own a 422 InvalidBundle.
Files sent in resources join the tree. Beside a tree, each file of resources is added at its path from the tree's root: a file the tree already holds must have the same bytes, or it is a 422 InvalidResourcePath naming the path, and a file no step names is kept like any other file of the tree. The tree they make is held to the tree's limits again, so files that take it past one are a 422 MethodTreeTooLarge.
Sandbox-hosted only for custom Python. A tree that holds any .py file is honored only on a sandbox-hosted deployment, where the load reads the source without importing it and execution happens in an isolated sandbox. On a deployment that is not sandbox-hosted it is refused with 403 CustomCodeRequiresSandbox before anything is written: running caller-supplied code in-process is never done implicitly. A tree without Python is accepted on any deployment.
No Python structure classes. A structure class (a StructuredContent subclass) can only be used by importing its module into the runner's own process, which a sandbox-hosted deployment never does, so a tree whose Python declares one is refused with a 403 (MethodStructuresRefusedError) naming each file and class, before anything is loaded. The rule is the same however the method arrives: sent as a tree, or fetched by method_ref. Declare the types as MTHDS concepts with inline structures instead; a PipeFunc returns them by importing the classes the sandbox generates from those concepts (from structures import <domain>__<Concept>). The structures module that pipelex build structures writes is accepted as long as it is left as generated, and it is not sent to the sandbox, which generates its own.
Requirements are checked against the sandbox. A requirements.txt at the root of the tree declares the packages its Python imports, one PEP 508 requirement per line (see Method Trees). Nothing is installed into a running sandbox: the packages the deployment states its PipeFunc sandbox offers for the method's requirements are the whole offer, a fixed package set or an environment the deployment builds for those requirements, and a method asking for a package the sandbox does not offer, or for a version it does not offer, is refused with a 422 (MethodRequirementsNotMetError) naming each requirement and the version offered, before the method loads and before anything is dispatched. A deployment that can build no environment holding the requirements refuses them with the same 422, carrying its own explanation. The refusal names the file by its tree path, requirements.txt, and no path on the server. A line the standard refuses, such as -r base.txt, a URL or a comment after a requirement, is a 422 (MethodRequirementsFileError) naming the line. A deployment that states no package set for its sandbox refuses every method declaring requirements with a 500 (PipeFuncPackageSetUnstatedError), a fault of its configuration, and so does a method of the server's own library directories whose requirements are not met (HostLibraryRequirementsError), whose message is withheld from the caller. A method_ref package is checked the same way, on a run as on /validate, where the refusal is an is_valid: false verdict located on the requirement's line.
Sending files by hash
This runner keeps a blob store, declared by blob_store: true in GET /v1/version, so a sender uploads each file at most once and then sends the method by hash: a second run of an unchanged method uploads nothing, and editing one template uploads one file. These are the MTHDS Protocol's blob routes.
- The sender lists its method's tree and computes each file's SHA-256 and the tree's digest.
- It asks
POST /v1/blobs/missingwhich blobs the runner lacks, sending{"blobs": [{"sha256": "…", "size": n}, …], "digest": "sha256:…"}, thedigestbeing optional. - It uploads each missing blob as the answer says: the blob's bytes as the whole body of a request with the answer's
methodto itsurl, carrying exactly itsheaders. - It sends the run, or the validation, with a hash entry for each file it chose to send by hash.
The answer is {"missing": [{"sha256": "…", "size": n, "upload": {"method": "PUT", "url": "…", "headers": {"content-type": "application/octet-stream"}}}, …]}, listing only the blobs the runner does not hold; an empty list means there is nothing to upload. Each target is the runner's own PUT /v1/blobs/{sha256}, its query signed for that hash and the caller's scope and valid for BLOB_UPLOAD_URL_TTL_SECONDS (default an hour). The target starts with PUBLIC_BASE_URL when the deployment sets it, the address its clients reach the API at, which a deployment behind a proxy terminating TLS or adding a path prefix sets, and otherwise with the URL the request arrived at. The upload route needs no bearer token, since the signed target is its authorization: a missing, malformed, forged or expired signature is a 403 BlobUploadNotAuthorized, a body whose SHA-256 is not the path's a 422 BlobMismatch, and a body past MAX_METHOD_TREE_FILE_KIB a 422 MethodTreeTooLarge. A blob is written whole and never changed, so uploading bytes the runner holds answers 204 and changes nothing. On POST /v1/blobs/missing, one hash listed with two sizes is a 422 BlobMismatch, and blobs past the tree limits a 422 MethodTreeTooLarge, both before anything is uploaded.
Writes are create-only. The runner creates a blob, or a tree's listing, only when its key holds nothing, and never replaces an object already there: on S3 the write carries If-None-Match: * and its SHA-256 as x-amz-checksum-sha256, which S3 checks against the body, on GCS it is conditional on generation 0, and on the local filesystem the file is created through a hard link, so the local storage's folder must sit on a filesystem that supports them. A key already holding an object is the object already held, the same bytes since the key is their hash, and the write succeeds without changing it. A deployment's bucket can therefore refuse every unconditional write under blobs/ and trees/ without refusing any of the runner's, as the hosted platform's does.
Sizes. A blob is held at a size, which the runner reads from the storage's metadata without reading the bytes. POST /v1/blobs/missing lists a blob the runner does not hold at the size the sender states, an object held at another size included. Its upload then tells the two cases apart: bytes of the size held are the ones held, and change nothing, while bytes of another size mean the object held is damaged, which only a storage that does not publish a write whole could leave, a 500 BlobStoreCorrupted, since nothing writes over a blob and the deployment's operator removes the object for it to be uploaded again. On a run or a validation, every hash entry is looked up by size before any byte is read: a hash the runner holds at another size than the entry states is a 422 BlobMismatch whose mismatched_blobs member gives each hash with its held_size, since the bytes under a hash have one size and uploading them again would change nothing, so the entry's size is the one to correct. Only bytes held at the size their entry states are read, a bounded number at a time, and checked against their hash; bytes that do not hash to their key mean the deployment's storage is damaged, a 500 BlobStoreCorrupted that its operator repairs by removing the object, for it to be uploaded again.
The digest shortcut. Once the runner has read a request's tree in full, it records the tree's listing beside its blobs, after every file it names is held, and an upload never records one. A validation records it only when its request carries a read_scope: without one, its scope would be the caller's own id, where a dry run must leave nothing stored. Asking POST /v1/blobs/missing with the digest of a recorded tree then answers an empty list in one lookup, however many files the tree has, when every blob listed is a file of that tree; a blob outside it is looked up as if no digest were sent.
Scopes. Blobs live in the deployment's configured storage, under <scope>/blobs/<sha256>, and recorded listings under <scope>/trees/<digest>. The scope is the request's read_scope, which a multi-tenant host sends for the organization it serves (a query parameter on POST /v1/blobs/missing, a body field on the run and validate routes), else the caller's own id, so a hash entry resolves only in the caller's own blobs and asking whether a hash is held tells a caller nothing of another's files. On a jwt deployment a sent scope must lie under the caller's own id, else a 403 ReadScopeForbidden (see Host-supplied run context). A hash entry the runner does not hold, at the size it states, is a 422 MissingBlobs whose missing_blobs member lists the hashes to upload before sending the request again.
Signing key. Each process draws its own signing key unless BLOB_UPLOAD_SIGNING_KEY is set. A deployment serving one API through several instances must set it, the same on every instance, since an upload may reach another instance than the one that named its target.
Sending the method packages the method calls
A method can call a pipe of another method package by its address, such as github.com/Pipelex/methods/documents->documents.extract_page_contents_and_views. A request can carry that package with the method, so the run needs no copy installed on the server and no fetch:
- With
files, send each package independencies, a map from the package's address to the package's own map of file entries, itsMETHODS.tomlat its root declaring that address. A package keyed by another address than its manifest declares is a422 InvalidMethodFiles, and so isdependencieswithoutfiles. - In a
bundle_b64archive, put each package under.mthds/methods/<name>/, with itsMETHODS.tomlat.mthds/methods/<name>/METHODS.toml, which is the layout of a local method store. The store's.provenance.jsonsidecar is left out of the package.
bundle.mthds
.mthds/methods/documents/METHODS.toml
.mthds/methods/documents/documents.mthds
- A reference finds the package by its manifest. The package that answers is the one whose
METHODS.tomldeclares the referenced address and name. A reference pinned with@<tag>is answered by the shipped copy too, and the server logs a warning when the copy'sversionis not that tag. - The shipped copy comes first. The server searches the shipped packages before the copies installed on it and before fetch-on-miss, so a shipped package wins over an installed copy of the same address, and it runs even where fetching is disabled or the address is not one the server can fetch. It serves this request alone: nothing is installed.
- It is a tree of its own. Each package is written into a methods directory of its own, never into the method's tree, so its domains never merge with the method's, and a method whose only bundles are shipped ones is refused as holding no bundle of its own. Its keys are held to the tree's rule as the method's are.
- Its Python never runs in the server's process. A
.pyfile in a shipped package counts for the sandbox gate like any other. On a sandbox-hosted deployment, a shipped package whose Python declares a structure class is refused with403 MethodStructuresRefusedErrornaming the package's address, as a package fetched by address is. - A shipped bundle that does not parse refuses the run with the
422verdict, whose items name the file as<address>/<path inside the package>, never by a path on the server.
Two shipped packages a reference could not tell apart, whose manifests declare the same address and name compared without regard to case, the name being the directory's when the manifest has none, are a 422 InvalidMethodFiles naming the address, since nothing but their order would decide which one answers. In an archive, a package directory without a valid METHODS.toml is refused the same way, and any other entry under .mthds/, such as a file directly under .mthds/methods/, one under a directory whose name no package's directory can take, such as a dot directory, or a package's own .mthds/ store, is refused by the tree's rule; a package fetched by method_ref holds its .mthds/methods/ to the same rule, its other entries left out of its tree. A package whose manifest declares no name takes the last segment of its address, which must then be a name a directory can take: a dependency keyed by an address ending in . or .. with a nameless manifest is a 422 InvalidMethodFiles.
The validation of a method sent as a tree, or fetched by method_ref, loads its shipped packages too, so /validate resolves a dependency the run would resolve.
Running a method by address (method_ref)
Both /execute and /start accept method_ref — a globally resolvable address of a published method package, resolved by this server (a Pipelex-API extension; the hosted-only catalog selector method_id is a different, platform-resolved field that never reaches this runner):
{
"method_ref": "github.com/Pipelex/methods/documents@v0.1.0",
"inputs": { "document": "https://example.com/report.pdf" }
}
The reference grammar is <address>[@<tag>]. The address is github.com/<owner>/<repo> optionally followed by a package selector within the repository (github.com only for now). A bare address means the repository's default branch at HEAD; @<tag> pins the repository at that git tag (recommended form vX.Y.Z) — branch names are refused, so a reference can never silently track a moving branch. Full browser URLs (https://github.com/..., including /tree/<branch>/... deep links) are accepted and normalized.
Resolution. The server fetches the repository (shallow clone, at the tag when one is named), records the commit SHA of what was fetched, and locates the requested package by manifest identity: it scans the clone for METHODS.toml manifests and selects the one whose declared address (plus name, for a package in a library repo) equals the requested address — never by directory path. It then reads the package's directory from the commit's git objects, never from the clone's working tree, and lists it as a method tree under the standard's rule: the package is a tree like one sent as files, written whole and loaded as the caller's, its templates and Python beside its bundles, and the packages it ships under its own .mthds/methods/<name>/ become dependency trees (Sending the method packages the method calls), under the same refusals.
Entry pipe. The pipe to run is the tree's entry pipe by the protocol's rule, the package manifest's main_pipe; an explicit pipe_code in the request overrides it (to run another pipe from the fetched package).
Provenance. The response carries method_provenance = {address, tag, commit_sha, tree_digest} — on /execute only for method_ref runs, on the /start ack as a nullable field. Tags can move; the recorded SHA is what keeps a run explainable after the fact, and tree_digest names the exact tree the run loaded.
Custom Python follows the tree rules, plus one more. What decides is where the code would execute: .mthds content is data, always acceptable. On a deployment that is not sandbox-hosted, a fetched package carrying any .py is refused with 403 CustomCodeRequiresSandbox. On a sandbox-hosted deployment, PipeFunc .py is accepted (captured as text, executed in the isolated sandbox) — but a package declaring Python structure classes (StructuredContent subclasses) is refused with a 403 (MethodStructuresRefusedError), exactly as a tree sent with the request is: see No Python structure classes.
Errors. Every resolution failure is a distinct RFC 7807 problem+json with the originating class as error_type (see Error responses):
| Failure | Status | error_type |
|---|---|---|
| Reference does not match the grammar | 422 | MethodRefParseError |
Repository could not be fetched, or @<tag> is not a tag |
422 | MethodFetchError |
| No package matches the address (message lists what the repo contains) | 404 | MethodPackageNotFoundError |
| More than one package matches | 422 | MethodPackageAmbiguityError |
| Package exceeds the fetched-package ceilings | 422 | MethodPackageTooLargeError |
| Package contains a symlink (file or directory) | 422 | MethodPackageSymlinkError |
| Package declares Python structure classes | 403 | MethodStructuresRefusedError |
Package ships .py on a non-sandbox deployment |
403 | CustomCodeRequiresSandbox |
The clone cache. Fetched repositories are cached on local disk keyed by resolved commit SHA — never by tag. Each request first resolves the reference to its current SHA with a cheap git ls-remote (no clone); a cached SHA is reused, a new one (a moved tag, an advanced HEAD) triggers a fresh clone. The cache is per server instance and bounded by count, total bytes, and age, oldest first; a clone a request is still reading is never evicted, though it counts toward the bounds. That protection holds within one process, so a deployment running several worker processes gives each its own METHOD_CACHE_DIR.
Environment knobs. The cache: MAX_METHOD_CACHE_CLONES (default 64), MAX_METHOD_CACHE_TOTAL_KIB (default 512 MiB), MAX_METHOD_CACHE_AGE_HOURS (default 24), METHOD_CACHE_DIR (default: a fixed directory under the system temp dir). The fetch itself is bounded by the pipelex runtime's fetched-package ceilings: PIPELEX_MAX_FETCHED_PACKAGE_FILES (default 256), PIPELEX_MAX_FETCHED_PACKAGE_TOTAL_KIB (default 8 MiB), and the manifest-scan bounds PIPELEX_MAX_SCANNED_MANIFESTS (default 100 manifests considered per fetched repository) and PIPELEX_MAX_MANIFEST_FILE_KIB (default 256 KiB per METHODS.toml read during package location). All are read once at process start.
method_ref also selects the method on the tooling routes: on POST /v1/validate, /resolve and /codegen and /pipe-io, which read the fetched package's tree as a run does (address form resolved; on the crate routes the registry form stays a 501).
Host-supplied run context: storage_scope and analytics_groups
Both /execute and /start accept optional fields that describe the run's tenancy rather than its method: storage_scope, read_scope and analytics_groups. All are Pipelex-API extensions, and all are data the host computes, which is why they ride in the body: the caller's identity arrives separately, on the trusted X-User-Id header or the bearer token (see Authentication), because it is something a proxy vouches for rather than something the caller chooses. The runtime carries them opaquely and never reads a value by name, so it has no notion of what an organization or a tenant is.
{
"pipe_code": "your_pipeline_code",
"inputs": { "input_name": "..." },
"storage_scope": "org_acme/method_42/run_7",
"read_scope": "org_acme",
"analytics_groups": { "organization": "org_acme" }
}
storage_scope is the prefix every object the run writes lands under: one to three path-safe segments, onto which the runtime composes its own leaves (assets/, generated/, results/, payloads/). Omit it and the run is scoped to the caller's own id, which is right for a single-tenant deployment and wrong for a multi-tenant one: a host serving many tenants must send it. Each segment is one or more characters from A-Za-z0-9_-, so an empty segment, a leading or trailing slash, a . or .., or a fourth segment is refused with a 422 whose error_type is InvalidStorageScope.
read_scope bounds what the run may read: every pipelex-storage:// key the run reads, whether an input, a URL a pipe builds or an image it shows a model, must lie under it, and a scoped run reads no local path at all. It follows the same segment rule as storage_scope, and the storage scope must be the read scope or lie under it, since a run reads back what it writes: org_acme covers org_acme/method_42/run_7, while org_other does not. Omit it and the run is scoped to the caller's own id when the deployment identifies its callers, which is also where their writes land by default, and unscoped when the deployment is single-tenant. A host serving many tenants must send it: a host that sends its own storage_scope but no read_scope is refused, since that storage scope lies under no caller's id. A malformed read scope, a storage scope outside it, or the local storage scope local beside one is a 422 whose error_type is InvalidReadScope, answered before any method is fetched or library loaded. On a jwt deployment the read scope is bound to the caller: the caller reaches the runner with its own token rather than through a trusted host, so a read scope it sends must be its token's user_id or lie under it, else the request is a 403 whose error_type is ReadScopeForbidden, answered before anything is read. Every route taking a read_scope applies the same rule (/v1/validate, /v1/resolve, /v1/codegen, /v1/pipe-io and POST /v1/blobs/missing too), and since the storage scope must lie under the read scope, a jwt caller writes only under its own id as well. With AUTH_MODE=none and the shared api_key, the caller is a trusted host or a single tenant, and the scope it sends is taken as it is. A read the scope does not allow is refused when the run makes it, with the error_type UriReadRefusedError, whose message says where the URL sat without quoting it. On /execute it is a 422. On /start it is a synchronous 422 when an input breaks the rule, and a run that ends FAILED with that error in its completion callback when a pipe reaches for the URL during the run.
analytics_groups names the entities the run's telemetry belongs to, as a mapping of group type to group key. The runtime carries it on the run as its opaque extras and stamps it on every span of the run as the attribute pipelex.run.extras, so every OpenTelemetry exporter receives it, and the deployment's own PostHog stream, when telemetry.toml sets mode = "identified", attaches it to each capture as PostHog groups. That is what lets a generation made for one of your customers appear inside that customer's organization instead of under one identity for the whole deployment. The rules are the runtime's own, imported rather than restated here:
- A group type (a key of the mapping) is lowercase snake_case starting with a letter, at most 32 characters, such as
organization. - A group key (a value) is 1 to 128 characters from
A-Za-z0-9_-. Whitespace, control characters and separators are refused, because the key is quoted into log lines and capture payloads. - The mapping holds at most five entries, since a telemetry backend registers only a small fixed number of group types per project.
- It is optional. Omitting it, sending
nullor sending{}all mean the run belongs to no group, which misattributes nothing.
A mapping outside these rules is refused with a 422 whose error_type is InvalidAnalyticsGroups, before any method is fetched or any library loaded. A body that fails on more than one of the extension fields at once answers with the generic ValidationError, and its detail names every field that failed.
The caller's id is the run's person on that same stream, with one exception: a deployment with no user model attributes every run to the literal single-tenant, which the runtime declines as a person and replaces with the user_id configured in telemetry.toml. The groups do not depend on it, so a single-tenant deployment that sends analytics_groups still has them on every capture.
Input Format: PipelineInputs
The inputs field accepts several shapes. Pipelex picks how to interpret each value from its structure — pass a string to a Text input and you get TextContent, pass a dict with concept and content keys and you get explicit concept resolution, etc. The cases below enumerate every supported form.
The request body is plain JSON. The Python objects in Cases 1.3 to 1.5, and the DictStuff instances further down, are what a Python caller writes; a Python client of this API serializes each one to plain JSON before sending it, and the server never rebuilds a Python class from the request. The class name does not travel, so each value is read against the entry pipe's declared input. A structured object (Case 1.3) arrives as its fields and a list of them (Case 1.4) as a list of those, and both are read as intended; a DictStuff arrives as the Case 2 envelope. A ListContent wrapper (Case 1.5) arrives as {"items": [...]}, which the server reads as one item rather than a list, and a native TextContent arrives as {"text": ...}, which a Text input refuses: over HTTP, send the bare list of items or strings instead, or the {"concept": ..., "content": [...]} envelope of Cases 2.2, 2.4 and 2.6. A body carrying a __class__ or __module__ key, or a key starting with __kajson, in any object at any depth is refused with a 422 ReservedObjectKey (see Error Responses).
How Input Formatting Works
Case 1: Direct Content - Provide the value directly (simplest)
- 1.1: String →
"my text" - 1.2: List of strings →
["text1", "text2"] - 1.3: StructuredContent object →
MyClass(arg1="value") - 1.4: List of StuffContent objects →
[MyClass(...), MyClass(...)] - 1.5: ListContent of StuffContent objects →
ListContent(items=[MyClass(...), MyClass(...)])
Note: Cases 1.3 and 1.5 are at the same level - both handle content types that inherit from StuffContent, but for different purposes (custom classes vs. list wrappers).
Case 2: Explicit Format - Use {"concept": "...", "content": "..."} for control (plain dict or DictStuff instance)
- 2.1: String with concept →
{"concept": "Text", "content": "my text"} - 2.2: List of strings with concept →
{"concept": "Text", "content": ["text1", "text2"]} - 2.3: StructuredContent object with concept →
{"concept": "Invoice", "content": InvoiceObject} - 2.4: List of StructuredContent objects with concept →
{"concept": "Invoice", "content": [...]} - 2.5: Dictionary (structured data) →
{"concept": "Invoice", "content": {"field": "value"}} - 2.6: List of dictionaries →
{"concept": "Invoice", "content": [{...}, {...}]} - 2.7: File / document URL →
{"concept": "Document", "content": {"url": "https://…/file.pdf"}}
Pro Tip: For text inputs specifically, skip the verbose format. Just provide the string directly: "text": "Hello" instead of "text": {"concept": "Text", "content": "Hello"}
Case 1: Direct Content Format
When you provide content directly (without the concept key), Pipelex intelligently infers the type.
1.1: Simple String (Text)
The simplest case - just provide a string directly:
{
"inputs": {
"my_text": "my text"
}
}
Result: At a Text input, automatically becomes TextContent with concept native.Text
The input's declaration decides the reading for two native concepts. At an Anything input, the string becomes a native.Anything value holding the text, and a number, a boolean or an object is taken the same way, as a value of its own kind; a date sent as a string is read as text. An Anything input refuses null and a list, both with a 422: a list is taken only by an Anything[] input, whose items are such values and can be neither null nor a list themselves. At a JSON input, a string is refused: a JSON input reads a JSON object literally, and a JSON[] input a list of objects. NaN and the infinities are refused at Number, JSON and Anything inputs, since JSON cannot carry them.
1.2: List of Strings (Text List)
Provide multiple text items as a list:
{
"inputs": {
"my_texts": ["my text1", "my text2", "my text3"]
}
}
Result: Becomes a ListContent containing multiple TextContent items
Note: The concept must be compatible with native.Text or an error will be raised. The one exception is an Anything[] input, where the list keeps the native.Anything concept and holds each string as text.
1.3: StructuredContent Object
Provide a structured object directly (for Python clients):
# Python client example
from my_project.domain.domain_struct import MyConcept, MySubClass
inputs = {
"invoice_data": MyConcept(arg1="arg1", arg2=1, arg3=MySubClass(arg4="arg4")),
}
What is StructuredContent?
StructuredContentis the base class for user-defined data structures in Pipelex- You create your own classes by inheriting from
StructuredContent - These classes are defined in your project's Python files
- Learn more: Python StructuredContent Classes
Concept Resolution (in-process calls only, where the object itself reaches the runtime; over HTTP the class name does not travel, and the declared input decides):
- The system searches all available domains for a concept matching the class name
- If multiple concepts with the same name exist in different domains → Error: Must specify domain
- If no concept is found → Error
1.4: List of StuffContent Objects
Provide multiple content objects in a plain Python list:
# Python client example
inputs = {
"invoice_list": [
MyConcept(arg1="arg1", arg2=1, arg3=MySubClass(arg4="arg4")),
MyConcept(arg1="arg1_2", arg2=2, arg3=MySubClass(arg4="arg4_2")),
],
}
What it accepts:
- Lists of
StructuredContentobjects (user-defined classes) - Lists of native content objects (
TextContent,ImageContent, etc.), in in-process calls only (over HTTP, see the plain-JSON note above)
Requirements:
- All items must be of the same type
- Concept resolution follows the same rules as 1.3
- Creates a new
ListContentwrapper internally
1.5: ListContent of StuffContent Objects
Provide an existing ListContent wrapper object (in-process Python calls only; over HTTP, send the bare list of items, as the plain-JSON note above explains):
# Python client example
from pipelex.core.stuffs.list_content import ListContent
inputs = {
"invoice_list": ListContent(
items=[
MyConcept(arg1="arg1", arg2=1, arg3=MySubClass(arg4="arg4")),
MyConcept(arg1="arg1_2", arg2=2, arg3=MySubClass(arg4="arg4_2")),
]
),
}
Key Difference from Case 1.4:
- Case 1.4: Plain Python list
[item1, item2]→ Creates a newListContentwrapper - Case 1.5: Already wrapped
ListContent(items=[item1, item2])→ Uses the wrapper directly
Why Case 1.5 is Separate from Case 1.3:
StructuredContentandListContentare sibling classes (both inherit fromStuffContent)- Case 1.3 handles user-defined structured data classes
- Case 1.5 handles list container wrappers
- They're at the same inheritance level, not parent-child
Requirements:
- All items within the
ListContentmust beStuffContentobjects (this includes bothStructuredContentand native content likeTextContent,ImageContent) - All items must be of the same type
- The
ListContentcannot be empty - Concept is inferred from the first item's class name (not from "ListContent")
Use Case: This format is useful when you already have data wrapped in a ListContent object from a previous pipeline execution or when working with Pipelex's internal data structures.
Case 2: Explicit Format (Concept and Content)
Use the explicit format {"concept": "...", "content": "..."} when you need precise control over concept selection or when working with domain-specific concepts.
2.1: Explicit String Input
{
"inputs": {
"text": {
"concept": "Text",
"content": "my text"
}
}
}
Concept Options:
"Text"or"native.Text"for native text- Any custom concept that is strictly compatible with
native.Text
2.2: Explicit List of Strings
{
"inputs": {
"documents": {
"concept": "Text",
"content": ["text1", "text2", "text3"]
}
}
}
Result: ListContent with multiple TextContent items
2.3: Structured Object with Concept
{
"inputs": {
"invoice_data": {
"concept": "Invoice",
"content": {
"invoice_number": "INV-001",
"amount": 1250.00,
"date": "2025-10-20"
}
}
}
}
Concept Resolution with Search Domains:
When you specify a concept name without a domain prefix:
- ✅ If the concept exists in only one domain → Automatically found
- ❌ If the concept exists in multiple domains → Error: "Multiple concepts found. Please specify domain as 'domain.Concept'"
- ❌ If the concept doesn't exist → Error: "Concept not found"
Using Domain Prefix:
{
"concept": "accounting.Invoice"
}
This explicitly tells Pipelex to use the Invoice concept from the accounting domain.
2.4: List of Structured Objects
{
"inputs": {
"invoices": {
"concept": "Invoice",
"content": [
{
"invoice_number": "INV-001",
"amount": 1250.00
},
{
"invoice_number": "INV-002",
"amount": 890.00
}
]
}
}
}
Result: ListContent with multiple structured content items
2.5: Dictionary Content
Provide structured data as a dictionary:
{
"inputs": {
"person": {
"concept": "PersonInfo",
"content": {
"arg1": "something",
"arg2": 1,
"arg3": {
"arg4": "something else"
}
}
}
}
}
The system will: 1. Find the concept structure (with domain resolution as explained above) 2. Validate the dictionary against the concept's structure 3. Create the appropriate content object
2.6: List of Dictionaries
{
"inputs": {
"people": {
"concept": "PersonInfo",
"content": [
{
"arg1": "something",
"arg2": 1,
"arg3": {"arg4": "something else"}
},
{
"arg1": "something else",
"arg2": 2,
"arg3": {"arg4": "something else else"}
}
]
}
}
}
2.7: File / Document Input
For pipes whose inputs declare Document, Image, or PDF (any URL-bearing native concept), provide a url inside content. The URL can be public HTTP(S), a pipelex-storage:// URI, or a base64 data URL.
{
"inputs": {
"cv": {
"concept": "Document",
"content": { "url": "https://example.com/resume.pdf" }
},
"headshot": {
"concept": "Image",
"content": { "url": "https://example.com/photo.jpg" }
}
}
}
Optional fields on Document content:
mime_type(string) — e.g."application/pdf". Inferred from the URL when omitted.filename(string) — the original filename. Auto-extracted from local-file URLs.title(string) — display title for the document.snippet(string) — text excerpt or description.public_url(string) — public HTTPS URL whenurlis a private/storage URI.
If you pass Document content as a list ([{"url": "…"}, {"url": "…"}]), Pipelex wraps it in a ListContent automatically — same rules as Case 2.4.
Using DictStuff Instances (Python Clients Only)
For Python clients, you can also pass DictStuff instances instead of plain dicts. DictStuff is a Pydantic model with the same structure as the explicit format.
from pipelex.client import PipelexClient
from pipelex.core.stuffs.stuff import DictStuff
client = PipelexClient(api_token="YOUR_API_KEY")
# Using DictStuff instance with dict content
response = await client.execute_pipeline(
pipe_code="process_invoice",
inputs={
"invoice": DictStuff(
concept="accounting.Invoice",
content={
"invoice_number": "INV-001",
"amount": 1250.00,
"date": "2025-10-20",
},
),
},
)
# Using DictStuff instance with list of dicts content
response = await client.execute_pipeline(
pipe_code="process_invoices",
inputs={
"invoices": DictStuff(
concept="accounting.Invoice",
content=[
{"invoice_number": "INV-001", "amount": 1250.00},
{"invoice_number": "INV-002", "amount": 890.00},
],
),
},
)
# Using DictStuff instance with list of strings (for Text concept)
response = await client.execute_pipeline(
pipe_code="analyze_texts",
inputs={
"documents": DictStuff(
concept="Text",
content=["document 1", "document 2", "document 3"],
),
},
)
DictStuff Structure:
concept(str): The concept code (with optional domain prefix)content(dict[str, Any] | list[Any]): The actual data content
Content Types:
- Dictionary: Single structured object → Creates a single Stuff
- List of dicts: Multiple structured objects → Creates ListContent with validated items
- List of strings (for Text-compatible concepts): Creates ListContent of TextContent
Note: DictStuff instances are automatically converted to plain dicts and processed through the standard Case 2 logic.
Search Domains Explained
When you reference a concept by name (like "Invoice" or "PersonInfo"), Pipelex needs to find it in your loaded domains.
Automatic Search
{
"concept": "Invoice"
}
What happens:
1. Pipelex searches all available domains for a concept named "Invoice"
2. If found in exactly one domain → ✅ Uses that concept
3. If found in multiple domains → ❌ Error: "Ambiguous concept: Found 'Invoice' in domains: accounting, billing. Use 'domain.Invoice' format."
4. If not found → ❌ Error: "Concept 'Invoice' not found in any domain"
Explicit Domain Specification
To avoid ambiguity, specify the domain explicitly:
{
"concept": "accounting.Invoice"
}
Format: "domain_name.ConceptName"
This tells Pipelex exactly which concept to use, bypassing the search.
Best Practices
- Use simple names (
"Invoice") when you have unique concept names across domains - Use domain-prefixed names (
"accounting.Invoice") when: - You have concepts with the same name in different domains
- You want to be explicit about which concept to use
- You're building APIs that need to be unambiguous
Multiple Input Combinations
Combine different input types in a single request:
{
"inputs": {
"text": "Analyze this contract for risks.",
"category": {
"concept": "Category",
"content": {"name": "legal", "priority": "high"}
},
"options": ["option1", "option2", "option3"],
"invoice": {
"concept": "accounting.Invoice",
"content": {
"invoice_number": "INV-001",
"amount": 1250.00
}
}
}
}
In this example:
textuses direct string format (Case 1.1)categoryuses explicit format with structured content (Case 2.5)optionsuses direct list format (Case 1.2)invoiceuses explicit format with domain prefix and structured content (Case 2.5)