This starter has two jobs: show how easy it is to call the Pipelex API, and be code you would actually copy into your own project. Both push in the same direction — the shortest possible reading path from a command to the SDK call it makes.
So the execution mode is not an option you pass, it is the command group you type:
widget blocking extract-entities # or summarize-pdf, generate-image
widget attended extract-entities # or summarize-pdf, generate-image
widget detached extract-entities # or summarize-pdf, generate-image
widget detached wait <run-id> # or status, result
widget/
cli.py # the assembler: load .env, mount the three groups in reading order. Nothing else.
inputs.py # SHARED — prepare the inputs (text/file; incl. the hosted file upload)
errors.py # SHARED — present an SDK error
usage.py # SHARED — read + print the run's cost report
blocking/cli.py # the whole blocking mode
attended/cli.py # the whole attended mode
detached/cli.py # the whole detached mode + the run-id lifecycle commands
Each mode file is a copy-paste unit: that file plus inputs.py plus errors.py plus usage.py is all the lifecycle code you need to lift the mode into your own project. A runnable copy also carries the method-specific artifacts the demos reference — the bundles (widget/methods/), the generated models (widget/generated/), and the sample document — but those are exactly what you replace with your own method anyway. No other code in widget/ is load-bearing for a mode.
Share what is orthogonal to execution. Never share lifecycle code.
Input preparation, error presentation, and cost-report formatting don't care how a run is executed, so they are shared. The lifecycle — what you call, in what order, and what you do while waiting — is the mode, so it lives in the mode's own file, in full, even when that means near-duplicating a demo command across three files. (Cost extraction is the one seam that differs per mode — the durable modes read a typed RunResults.tokens_usages, blocking lifts the same records raw off its execute result — so each helper picks the right usage.py reader; the formatting is shared.)
That duplication is deliberate, and it is the pedagogy: diff blocking/cli.py against attended/cli.py and the only thing that differs is the lifecycle helper. The moment two modes reach for a shared runner helper, a dispatch layer is back between the command and the SDK, and the reading path grows a hop. (The earlier version of this starter had exactly that: a --mode option → _dispatch() → match ExecutionMode → runner.py → the SDK. Four layers to answer "how do I call the API?".)
The duplication can drift, so tests/unit/test_mode_symmetry.py holds it in place: every demo exists in every mode, with the same parameters. A demo added to one mode and forgotten in another fails CI.
All three have the same four-part shape, so they diff cleanly:
- Module docstring — the mode's contract in a paragraph, plus its copy-paste contract.
- App + consoles — its own
typer.Typer, its ownConsole()(stdout, for results — pipeable) andConsole(stderr=True)(stderr, for progress chatter). - The lifecycle helper — one public async function that is the mode (
detachedadds the run-id lifecycle helpers described below). It gets a public name because it is the featured code, and it is what the unit tests patch and the e2e tests call directly. - The demo commands + a private
_run()— each command reads its input, reads its bundle, awaits the lifecycle helper through_run()(asyncio.run+ the singleexcept PipelineRequestErrorthat presents viawidget/errors.py), narrows the result into its generated model, prints JSON to stdout, brings down any file the result references, and prints the run's cost report to stderr.
The lifecycle helpers take the bundle as the method's tree, contents: dict[str, str] — each .mthds file's text keyed by its path in the method's directory, the tree's root — and pass it straight to the SDK, which sends it inline when it is small and by hash otherwise. The three demos are single-file methods, so each reads its main.mthds and sends it as contents={"main.mthds": bundle}. A method dir may instead hold several .mthds files (a multi-file bundle split across pipes with signature_for cross-file declarations); read them all with method_dir = METHODS_DIR / "<name>", then {path.relative_to(method_dir).as_posix(): path.read_text(encoding="utf-8") for path in sorted(method_dir.rglob("*.mthds"))} and hand that map to the helper unchanged. Concatenating the files into one string would be invalid TOML — the map is the interface for exactly this reason, and its keys are the paths a server diagnostic names.
| Mode | Lifecycle helper | SDK calls | What the demo prints |
|---|---|---|---|
blocking |
execute_pipe() |
client.execute |
the result as JSON, then any produced file and a cost report (stderr) |
attended |
start_and_wait() |
client.start + client.wait_for_result |
the run id (stderr), then the result as JSON + any produced file and a cost report (stderr) |
detached |
start_pipe() |
client.start |
the run id, bare, on stdout (no cost — the run isn't done yet) |
detached additionally owns the run-id lifecycle: attend_run() backs wait, and thin fetchers back status and result. It is the only mode with more than the demos, because "start now, collect later" is only a complete story if you can come back for the result. The cost report lands where the result does, so in detached mode it prints from wait and result (collection time), not from the start-only demo commands.
The result-producing helpers — execute_pipe(), start_and_wait(), and detached's attend_run() — all return the SDK's own RunResults. That is one object for the whole run: .main_stuff is the resolved output (the SDK types it Any — the content is polymorphic), .tokens_usages / .usage_assembly_error are what it consumed, and the produced files are referenced inside the output. The blocking mode reaches it through pipelex_sdk.execute_result.results_from_execute, which lifts the blocking response onto the same shape the durable path returns, so no mode reads the runner's raw pipe_output and every mode reads the same accessors. In blocking and attended mode each demo command narrows results.main_stuff with Model.model_validate(...), which is what restores type safety (the models are generated from the bundles — see codegen.md), then hands the whole results to the cost report and the download. Detached is different by design: start_pipe() returns only the run id, and the run-id commands (wait, result) print the output generically — at collection time the command doesn't know which method the run executed, so there is no model to narrow into.
Three SDK capabilities show up in every result-producing path:
- Cost report. A completed run reports what its inference calls consumed. The reading belongs to the SDK:
pipelex_sdk.usage.summarize_usage(results)folds the usage pair into oneUsageSummaryunder every rule the SDK'sdocs/run-usage.mdstates — an unrated call (cost is None: mock / own-GPU / dry-run) is not a zero-cost call, token categories are never summed (input_cachedis a subset ofinput), and a run that did no inference is told apart from a run nothing is known about.widget/usage.pyre-derives none of that;print_cost_reportreads the summary's state and totals and renders a per-call table, always to stderr, so stdout stays the clean, pipeable result. - Produced files. A run that generates an image or a document does not return the bytes: the output carries a durable
pipelex-storage://reference beside a signedpublic_urlthat expires, so the link must not be stored.widget/artifacts.pybrings the files down through the SDK's artifact stack —collect_artifactsanswers offline whether the output references any file at all, so a text result costs nothing, anddownload_artifactsmints a fresh link for each and saves it underDEFAULT_DOWNLOAD_DIR, never reading the embeddedpublic_urland never overwriting a file. It answers a verdict with errors as values, so a file that did not come down is reported rather than raised. Seedocs/artifact-download.mdinpipelex-sdk. - File upload.
summarize-pdffeeds a file to a pipe. A hosted run cannot see your filesystem, soinputs.py'supload_document_inputuploads the file first (client.upload_file) and the run request carries only the returnedpipelex-storage://URI — never the bytes. Preparation is a step of its own, before the run: an unreadable file or an upload-incapable deployment fails before any run is created, presented through the samewidget/errors.pypath as every other SDK error.
stdout is the result; stderr is everything else. Progress spinners, run ids in attended mode, error messages, and hints all go to stderr, so stdout stays pipeable. In detached mode the run id is the result, so it goes to stdout bare (print, not Rich) — RUN_ID=$(widget detached generate-image "…") just works.
SDK errors are presented once, at the root of the command. _run() catches PipelineRequestError (the base of the SDK's typed errors, a non-2xx answer from any route included), maps it to an ErrorPresentation (a message, the lines that explain it, and a hint) via widget/errors.py, prints it with print_error, and exits non-zero. print_error escapes every piece before it reaches Rich, because the message and its lines carry the server's text, and a bracketed span in it would otherwise be read as markup. Ctrl-C is handled separately: the durable lifecycle helpers catch the cancellation just long enough to print the resume hint before re-raising, and _run() maps the resulting KeyboardInterrupt to exit 130. Beyond those two, nothing is caught: an unexpected exception crashes loudly with its traceback, which is what you want while you are building. A request that gets no answer is not one of those: a connection that fails, from a mistyped PIPELEX_BASE_URL or a machine that is offline, or a time limit that runs out, reaches _run() as the SDK's ApiUnreachableError on every route, execute and start included, and is presented with the PIPELEX_BASE_URL hint.
A failure says why, where, who acts and what to do. Every error the SDK raises carries its failure as one MTHDS problem document, exc.problem, and widget/errors.py reads that problem, never the error's class. A request the API refused is the problem the API sent, on every route, the protocol routes (execute, start, runs/*) as much as the product ones; a failure the SDK detected itself, a connection that failed or a blocking run cut off before its answer came back, is a problem the SDK built under a registered code. problem_lines reads any problem out the same way, so a method the API will not run reads like this whichever mode you met it with:
Error: The API answered 422 Unprocessable Entity.
Reason: The method does not validate.
In pipe draft_pitch, field model: Pipe 'draft_pitch' (PipeLLM), field 'model': Model handle 'gpt-5.1' was not found in the model deck
Did you mean: gpt-5.5, gpt-5.4, gpt-5.6-sol, gpt-5.4-pro, gpt-5.6-luna
Who acts: whoever authors the method, by changing it.
Next step: Fix each fault the problem's errors list, then validate the method again.
Retry: running it again unchanged will fail the same way.
For support: method.invalid · request req_a3dd6900-7909-48d3-b551-0140e73ac7fc
Each line comes from one member of the problem:
- Reason is its
titleanddetail, the detail alone when the two say the same thing. The lines of a multi-line reason hang under its label. - The faults are its
errors, one line each, located by the pipe, field, input or file and line itslocationnames, with its suggestions after it. An item whose detail the reason already says is reduced to where it is, rather than printed twice. - Who acts reads its
domain: you for the inputs, whoever authors the method for the method, whoever owns the account for a credential or a plan, whoever runs the deployment for its configuration, and nobody on your side for the service itself. An API out of reach or silent past the time limit (client.unreachable,client.timeout) gets no such line, since the URL or the network is as likely a cause as the service. A domain this version of the SDK does not know reads as its code family's. - Next step is its
next_step.detail, the sentence the server wrote for the step'skind. A step that only says to retry or wait is left to the retry line. - Retry reads its
retryverdict: running it again can succeed (in so many seconds when the problem carriesretry_after), it will fail the same way, or the run may already have started, in which case look it up by its id before starting another, or it is paid for twice. - For support is what to quote when you ask for help: the run's id, the problem's
codeand the request id.
The hint is the CLI's own action, the one thing the server cannot know to say, and it is chosen by the problem's code or its step's kind, never by its wording or the error's class. A refused credential (auth.*) gets the PIPELEX_API_KEY hint, an API out of reach (client.unreachable) the PIPELEX_BASE_URL one, and a server that does not run durable runs (runtime.unsupported, naming the durable run lifecycle or asynchronous execution) a hint pointing at widget blocking. When the next step is the advice, no hint competes with it.
A failed run says why. A durable run that ended without a result is the platform's run.failed problem, with the run's own problem nested in it as run_error: the one the runner stored when the run failed, or the one the platform wrote when it ended or settled the run itself. The SDK raises it as RunFailedError out of wait_for_result, start_and_wait and an artifact download, and returns the same problems on the failed arm of get_run_result and on the status read's RunRead.error. present_failed_run presents all three the same way, from the run's own problem:
Error: Run 3f2a… failed.
Reason: The provider refused the content — The model 'gpt-5.5' or its provider refused the request's content on policy grounds.
Who acts: you, by changing the inputs or the request.
Next step: Change the inputs so that they meet the provider's content policy, then send the request again.
Retry: running it again unchanged will fail the same way.
For support: run 3f2a… · model.content_refused
widget detached status <id> prints the same lines under the status, so a failed run reads the same whichever command you met it with. A run that ended with no problem of its own, one whose start failed or one cancelled, terminated or given its status by hand with no completion callback carrying one, says that no reason was recorded, keeps the platform's own sentence (on a stored result the platform refuses to serve, that sentence is the only thing that says what happened), and hints at what is left: support for a failure, starting it again for a run that was stopped. The platform serves the run's problem with the runtime's strict disclosure, so neither the worker's internal text nor a provider's raw answer reaches the terminal.
The other hints name the mode groups, because when a mode cannot carry a run, the fix is usually another group: a blocking run cut off at the ~30s cap tells you to rerun it with widget attended; a run still going when your wait ended tells you to look it up with widget detached status <id> or wait for it with widget detached wait <id>; a durable run against a runner that can't do them tells you to use widget blocking. Waiting itself honours the server's retry_after: the SDK's wait_for_result polls no sooner than a busy server asks, so no mode keeps a poll loop of its own.
Every demo runs with zero arguments. When you give neither an argument nor --file, the input helper returns a bundled sample (widget/inputs.py's SAMPLE_* constants), and the command prints a one-line notice on stderr saying so. A fresh clone shows a working result on its very first command once your API key is set; stdout stays the clean, pipeable result because the notice is on stderr. Sample data is orthogonal to execution, so like input encoding it is shared, not duplicated per mode.
A demo names its pipe by its qualified reference. Each call sends pipe_code="<domain>.<pipe_code>" — extract_entities.extract_entities, the bundle's own domain and its main_pipe — never the bare code. The runtime keys a pipe by exactly that reference, while a bare code is searched for across every domain of the bundle and fails as ambiguous once two domains declare the same one, which a bundle you grow from a demo can easily come to do. The qualified form ties the call site to the bundle's domain, so rename the two together; tests/unit/test_mode_symmetry.py reads the reference from each bundle and fails when a call site no longer matches it.
Both start the same durable run — one that lives server-side behind an id and outlives your terminal. The only difference is who waits: attended polls from your terminal, detached exits and lets you collect the result later. Naming the middle one "durable" would suggest detached is not, which is exactly backwards. The mode names the axis that actually differs.
A nice consequence: Ctrl-C during an attended run doesn't lose anything — it has just turned into a detached run, and the hint you get says so (widget detached wait <id>).