@pipelex/sdk 0.59.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ Three routes project a **closure** of MTHDS files into the artifacts downstream
6
6
  >
7
7
  > Measured 2026-08-23 against `api.pipelex.com` (`pipelex-hosted@0.10.1`) with a real API key: an empty body to either route comes back as an RFC 7807 `422` naming the missing fields — a request-shape verdict only a route that reached the service can produce — and a real closure comes back as a `200` carrying the artifacts. `api-dev.pipelex.com` has served them since 2026-08-13. Use a key when re-measuring: unauthenticated, every path answers `401` whether or not it is allowlisted, so a keyless probe cannot tell the two states apart.
8
8
  >
9
- > **`pipe-io` is newer.** A runner serves it from `pipelex-api` v0.33.0, and codes its pipe-selection refusals `request.pipe_not_found`, `request.pipe_unspecified` and `request.pipe_ambiguous` from v0.90.0, which is what `prepareInputs` branches on (see [What throws](#what-throws)). A hosted origin serves the route once the platform's tooling proxy and the gateway's allowlist both list it, and until then it answers the gateway's `403 {"message":"Forbidden"}`. `prepareInputs` reads this route, so it needs an origin that serves it.
9
+ > **`pipe-io` is newer.** A runner serves it from `pipelex-api` v0.33.0, and codes its pipe-selection refusals `request.pipe_not_found`, `request.pipe_unspecified` and `request.pipe_ambiguous` from v0.90.0, which is what `prepareInputs` branches on (see [What throws](#what-throws)). The platform's tooling proxy and the gateway's allowlist list it too, and the dev plane serves it (measured on `api-dev.pipelex.com` on 2026-10-31). `prepareInputs` reads this route, so it needs an origin that serves it.
10
10
  >
11
11
  > **`lint` / `format` are the exception and answer `403` on both origins.** That blocks nothing, because linting and formatting `.mthds` are toolchain capabilities rather than hosted ones: `plxt` carries both, and the post-edit hook this repo builds (`npm run build:hook`, vendored into `pipelex-plugins`) runs them offline through `@pipelex/tools-wasm` with no credentials — from the published package, `client.lint` / `client.format` are the documented fallback, against a runner. Exposing them on the hosted origins is a known, non-critical item on the platform's list, tracked in the workspace ledger as L-260929-b58f26.
12
12
 
@@ -21,7 +21,7 @@ interface CrateRequestBase {
21
21
  tree?: MethodTreeSource; // or a listed tree, sent inline or by hash
22
22
  contents?: Readonly<Record<string, Uint8Array | string>>; // or each file's bytes or text by tree path
23
23
  dependencies?: Record<string, MethodFiles>; // beside the tree: the packages it calls by address
24
- method_ref?: string; // address form server-resolved; registry form reserved → 501
24
+ method_ref?: string; // address form server-resolved; registry form reserved → 422 request.unsupported
25
25
  read_scope?: string; // Pipelex-API extension; the hosted platform stamps its own
26
26
  }
27
27
 
@@ -49,7 +49,7 @@ When the engine can attribute a fault to a file, the `errors[]` problem item's `
49
49
 
50
50
  `dependencies` carries the method packages the tree calls by address, each a `files` map keyed by its address, posted as given. They are loaded beside the method, never merged into it, so a cross-package call resolves without the server fetching anything. A call into a package nothing shipped makes the closure invalid, a `200` verdict whose `unresolved_package_dependency` item names the package; `dependencies` without the tree is the server's `422`.
51
51
 
52
- An **address-form** `method_ref` (`github.com/<owner>/<repo>[/<selector>][@<tag>]`) is resolved by the server through the same fetch path as a `method_ref` run: the package's whole tree is read from the commit at the tag and loaded as a sent tree is, with the packages it ships under its own `.mthds/methods/` beside it. Any non-address reference stays reserved and answers `501`.
52
+ An **address-form** `method_ref` (`github.com/<owner>/<repo>[/<selector>][@<tag>]`) is resolved by the server through the same fetch path as a `method_ref` run: the package's whole tree is read from the commit at the tag and loaded as a sent tree is, with the packages it ships under its own `.mthds/methods/` beside it. Any non-address reference stays reserved until a method registry exists: the server refuses it with a `422` `request.unsupported` whose `params.reason` is `registry_form_method_ref` and whose `location.field` is `method_ref`, on a runner from `pipelex-api` v0.90.0, which answered `501` before, and on the hosted plane, which relays the runner's refusal.
53
53
 
54
54
  `method_id` is the hosted platform's selector, and it is a **pass-through**: nothing is expanded client-side — the platform resolves the id against the org's catalog and injects the stored tree as `files` before the runner sees the request. A bare `mt_…` names the method's latest published version, `mt_…@<n>` the fixed version `n` and `mt_…@draft` its draft, and the suffix rides the string untouched. It is meaningless against a bare runner (no catalog). An unknown or foreign-org id is a `404` `pipelex.catalog.not_found` (indistinguishable by design), as is a version the method never published; a bare id of a method never published is a `409` `pipelex.catalog.not_published`, whose detail names `mt_…@draft`; a malformed suffix is a `422`; a stored method with no MTHDS source is a `422`.
55
55
 
@@ -158,10 +158,10 @@ Only a **no-verdict** condition, as the typed `ApiResponseError` — branch on i
158
158
  | ------------- | --------------------------------------------------------------------------------------------------------- |
159
159
  | `422` | Request shape, `request.invalid` with its `location.field`: no closure selector or more than one, `dependencies` without the tree, an unknown `kind`/`target`, a `pipe_ref` on `kind: "types"`, a `method_id` whose version suffix is malformed; and a fetched package with no `.mthds` file or a stored method with no MTHDS source. |
160
160
  | `422` | The tree's refusals, as on `validate`: `method.package_invalid` for a key, an entry or a size the tree's rule refuses (a `files` that is not a map among them), `request.blobs_missing` for a hash the server does not hold, and a tree holding no `.mthds` bundle. |
161
- | `422` | On `pipeIo`, a pipe selection the route refuses: `request.pipe_not_found` for a `pipe_ref` that names no pipe, `request.pipe_unspecified` for a method with no entry pipe, `request.pipe_ambiguous` for a code that matches pipes in several domains or several `main_pipe` declarations. Its `params` name the candidates where there are some. |
161
+ | `422` | On `pipeIo`, a pipe selection the route refuses: `request.pipe_not_found` for a `pipe_ref` that names no pipe, `request.pipe_unspecified` for a method with no entry pipe or, with no manifest, several bundles' `main_pipe` declarations, `request.pipe_ambiguous` for a bare code that matches pipes in several domains. Its `params` name the pipe as `pipe` and the candidates as `candidates` where there are some; its `detail` is the registry's sentence for the code, which names no candidate for `request.pipe_unspecified`. A manifest `main_pipe` that no bundle or several define is the method's own fault instead, `method.pipe_unresolved` or `method.ref_ambiguous`. |
162
162
  | `404` | `pipelex.catalog.not_found`: an unknown or foreign-org `method_id` (indistinguishable by design), or a `mt_…@<n>` naming a version the method never published; no package at a `method_ref` address. |
163
163
  | `409` | `pipelex.catalog.not_published`: a bare `method_id` of a method never published; `mt_…@draft` reaches its draft. |
164
- | `501` | Registry-form `method_ref` — reserved, not implemented (the address form resolves). |
164
+ | `422` | `request.unsupported`: a registry-form `method_ref`, reserved until a method registry exists (the address form resolves). A runner older than `pipelex-api` v0.90.0 answers it `501`. |
165
165
  | `401` / `403` | Auth, or on a hosted origin a route the gateway does not list yet (`403 {"message":"Forbidden"}`). |
166
166
  | `403` | A tree holding a `.py` file on a deployment that is not sandbox-hosted, one whose Python declares a structure class on one that is, or a `read_scope` a `jwt` caller may not read under. |
167
167
  | `502` | On a hosted origin, a cold `method_ref` clone that outran the gateway's 30-second cap; a retry clears it. |
package/docs/errors.md CHANGED
@@ -96,6 +96,8 @@ What the hosted API answers a start sent under a key:
96
96
 
97
97
  **A copy takes a key too.** `copyMethod` sends an `idempotencyKey` the same way, held to the same rule, and the hosted API keeps it for the same 24 hours, scoped to the caller and the method's organization. A copy binds no run, so its answers under a key differ from a start's: [method-copy.md](./method-copy.md#retrying-a-copy--idempotencykey) gives them.
98
98
 
99
+ **So do a secret rotation and a delivery replay.** `rotateWebhookSecret` and `replayRunWebhookDelivery` send an `idempotencyKey` the same way, held to the same rule. A rotation's answer carries a secret, so the platform never replays it, while a replay's stored answer is given again: [webhooks.md](./webhooks.md) gives both.
100
+
99
101
  ## Each class's problem
100
102
 
101
103
  The classes remain, so a `catch` can still tell an argument refusal from a refused request or a failed run, but every one carries its failure as its problem, and the code is what a program branches on.
package/docs/files.md CHANGED
@@ -231,6 +231,7 @@ The answer is a `DraftFileFromFileResult`, a `200` discriminated on `blob`: `cop
231
231
 
232
232
  ```ts
233
233
  const method = await client.getMethod("mt_abc123");
234
+ if (!("updated_at" in method)) throw new Error("only the owner writes a method's draft");
234
235
  const added = await client.copyFileIntoDraft("mt_abc123", {
235
236
  file_id: "fl_0199",
236
237
  path: "assets/logo.png",
@@ -166,7 +166,7 @@ The route selects the pipe, and the ref it answers with is the one prepared —
166
166
 
167
167
  1. **`pipe_ref` when given.** Qualified-only: `domain.pipe_code`, normalized (trimmed, and blank is absent) and checked **before any request**. A bare code is refused with an `InputPreparationError` asking for the qualified form, since a request names a pipe by its qualified ref and search is a run-route affordance this helper never grows. An `alias->domain.pipe_code` ref is refused for a different reason: the alias names a dependency package's pipe, and preparation covers the method's *own* pipes, whose input form the pipe I/O route describes. The run route takes such a ref; preparation cannot.
168
168
  2. **Otherwise the method's own entry pipe**, chosen by the route: the `main_pipe` of the `METHODS.toml` at the tree's root, whether the tree was sent or fetched with a package, else the closure's `main_pipe` when exactly one domain declares one. A method that names its entry pipe in `METHODS.toml` alone — the published `github.com/Pipelex/methods/documents` does — therefore prepares with no `pipe_ref`.
169
- 3. **A selection the route refuses** — a `pipe_ref` that names no pipe of the method, a method with no entry pipe, one whose domains declare several, a manifest `main_pipe` the closure does not declare or declares in several domains — comes back as a `422` whose code is `request.pipe_not_found`, `request.pipe_unspecified` or `request.pipe_ambiguous`. The helper turns exactly those into an `InputPreparationError` carrying the server's problem and its detail, which names the candidates where there are some, with the `ApiResponseError` as its `cause`. The run route refuses a selector-less run in the same cases, so preparation never succeeds for a pipe the run would not take.
169
+ 3. **A selection the route refuses** — a `pipe_ref` that names no pipe of the method, a method with no entry pipe, one whose bundles declare several `main_pipe`s — comes back as a `422` whose code is `request.pipe_not_found` or `request.pipe_unspecified`. The route's third selection code, `request.pipe_ambiguous`, for a bare code several domains define, does not arise from here, since a bare `pipe_ref` is refused before any request, but the helper reads it as a refused selection too. The helper turns exactly those three codes into an `InputPreparationError` carrying the server's problem, whose `params` name the unknown ref as `pipe` and the refs the request could select as `candidates`, with the `ApiResponseError` as its `cause`; its message ends with the problem's `detail`, the registry's sentence for the code, which names no candidate for `request.pipe_unspecified`. A manifest `main_pipe` the closure does not define, or defines in several domains, is the method's own fault, a `422` `method.pipe_unresolved` or `method.ref_ambiguous`, which stays an `ApiResponseError`. The run route refuses a selector-less run in the same cases, so preparation never succeeds for a pipe the run would not take.
170
170
 
171
171
  There is no single-pipe fallback: a method that declares one pipe and no `main_pipe` is refused a selector-less run, and a selector-less preparation with it.
172
172
 
@@ -1,15 +1,17 @@
1
1
  # Copying a method into another organization
2
2
 
3
- A stored method belongs to the organization that holds it, and access to it comes from membership in that organization. `copyMethod` makes a new method in another organization the caller may change methods in, holding the original's draft, its form inputs and its saved examples, and leaves the original as it was. It is how a method reaches a team from the personal workspace that holds it, how it moves from a team into the caller's personal workspace, and, with the method's own organization as the destination, how a method is duplicated. `listMethodCopies` lists the copies made of a method, so a surface can say where they went. The client reaches them on the hosted API's `POST /v1/methods/{method_id}/copy` and `GET /v1/methods/{method_id}/copies`, and the types are in `src/product-models.ts`.
3
+ A stored method belongs to the organization that holds it, and access to it comes from membership in that organization. `copyMethod` makes a new method in another organization the caller may change methods in, holding the original's draft, its form inputs and its saved examples, and leaves the original as it was. It is how a method reaches a team from the personal workspace that holds it, how it moves from a team into the caller's personal workspace, and, with the method's own organization as the destination, how a method is duplicated. The client reaches it on the hosted API's `POST /v1/methods/{method_id}/copy`, and the types are in `src/product-models.ts`.
4
4
 
5
5
  ```ts
6
6
  import { PipelexApiClient } from "@pipelex/sdk";
7
7
 
8
8
  const client = new PipelexApiClient();
9
9
  const method = await client.getMethod("mt_abc123");
10
+ // Another organization's public method has no draft to name: its copy takes no token.
11
+ const token = "updated_at" in method ? method.updated_at : null;
10
12
  const result = await client.copyMethod(
11
13
  method.method_id,
12
- { destination_org_id: "org_team", expected_draft_updated_at: method.updated_at },
14
+ { destination_org_id: "org_team", expected_draft_updated_at: token },
13
15
  { idempotencyKey: crypto.randomUUID() },
14
16
  );
15
17
  if (result.outcome === "refused") {
@@ -19,18 +21,17 @@ if (result.outcome === "refused") {
19
21
  }
20
22
  ```
21
23
 
22
- ## The methods
24
+ ## The method
23
25
 
24
26
  | Method | Route | Answer |
25
27
  |---|---|---|
26
28
  | `copyMethod(methodId, { destination_org_id, name?, expected_draft_updated_at? }, { idempotencyKey? })` | `POST /v1/methods/{id}/copy` | A `MethodCopyResult`, discriminated on `outcome`: `copied` or `refused`. |
27
- | `listMethodCopies(methodId, { limit?, cursor? })` | `GET /v1/methods/{id}/copies` | One `MethodCopyPage`, `{ items, nextCursor }`, of `MethodCopyMarker` rows. `limit` is 1 to 100, 50 by default. |
28
29
 
29
- Both take a bare catalog id, as every method route does, and refuse a suffixed one (`mt_…@3`) with a `RequestArgumentError` before anything is sent.
30
+ It takes a bare catalog id, as every method route does, and refuses a suffixed one (`mt_…@3`) with a `RequestArgumentError` before anything is sent.
30
31
 
31
32
  **The copy's body is closed.** The client sends `destination_org_id` and, when given, `name` and `expected_draft_updated_at`, and nothing else; a `null` reads as not given. `destination_org_id` is the canonical id of the destination, as a membership's `org_id` gives it; `name` is the copy's name, the original's when it is left out; `expected_draft_updated_at` is the original's draft token the caller last saw (`MethodData.updated_at`). A `destination_org_id` that is not a non-empty string, and a `name` or a token given that is not a string, are refused with a `RequestArgumentError` before any request.
32
33
 
33
- **Only a web session may copy.** A Pipelex API key belongs to one organization and cannot speak for another, so the platform refuses a copy sent with one `403` `auth.forbidden`, whatever its role. The listing is a read, open to every member of the method's organization and to every credential.
34
+ **Only a web session may copy.** A Pipelex API key belongs to one organization and cannot speak for another, so the platform refuses a copy sent with one `403` `auth.forbidden`, whatever its role.
34
35
 
35
36
  ## What a copy holds
36
37
 
@@ -44,10 +45,12 @@ Both take a bare catalog id, as every method route does, and refuse a suffixed o
44
45
  | Published versions | None: the copy starts unpublished, its `latest_version` and `latest_published` `null`, and its own publish makes version 1 |
45
46
  | Runs | None: a run stays in the organization that ran it and was billed for it |
46
47
  | `method_id`, `org_id`, `created_by_user_id`, `created_at` | A new id, the destination, the copier and the moment of the copy |
47
- | `copied_from` | `{ org_id, method_id, draft_digest }` of the original, as it stood at the copy, its `draft_digest` `null` when the original's draft held no files |
48
+ | `copied_from` | `{ org_id, method_id, draft_digest }` of the original, as it stood at the copy, its `draft_digest` `null` when the original's draft held no files; for another organization's public method, `{ org_id, method_id, draft_digest, version }`, `version` the version copied and `draft_digest` always `null` |
48
49
 
49
50
  **The copy takes the draft, never the latest published version.** The draft is what the person sees in the editor, and the copy goes through its own first publish anyway; an invalid draft is copied as it is, and the copy's publish refuses it as it would any draft. With `expected_draft_updated_at`, the copy takes exactly the draft the caller last saw: a draft that moved since is a `409` `pipelex.catalog.update_conflict`, and nothing is copied. A client that has just saved the editor's pending edits sends the token that save answered.
50
51
 
52
+ **Another organization's public method copies from its latest published version** instead, since its draft is the owner's: that version's tree, description and categories, and the method's saved examples, but not the owner's form inputs. The copy's `copied_from` names the `version` it took. The caller never saw that method's draft, so `expected_draft_updated_at` sent with it is a `422` `request.invalid` naming the field. [public-methods.md](./public-methods.md) describes public methods.
53
+
51
54
  **A stored file named by an input is copied.** A `pipelex-storage://` address resolves only inside the organization it names, so every such address in the original's organization, in the form inputs and in each example's inputs, is copied into the destination's `assets/` under a new address, and the value is rewritten to it; a file named twice is copied once. A duplicate keeps every address of the organization as it is, an upload's above all, since the organization is the same, and copies only the files the original method's own runs stored, under `pipelex-storage://<org_id>/<method_id>/…`, which erasing the original would sweep away. An address of another organization, a malformed one and one whose file is gone are kept unchanged. The destination records each copied file among its files, attributed to the copier. `stored_files_copied` counts the distinct files copied.
52
55
 
53
56
  ## The verdict
@@ -73,17 +76,17 @@ A non-2xx answer means no verdict could be produced, and none of them leaves any
73
76
  |---|---|---|
74
77
  | `403` | `auth.forbidden` | The request is not from a web session, or the caller's role changes no method, `runner` or a role the platform does not know, in the method's organization or in the destination: the role must change. |
75
78
  | `404` | `pipelex.org.not_found` | The destination is one the caller does not belong to, one whose invitation they have not accepted, one still being set up, or one that does not exist. The platform answers them alike, so it never says whether an organization exists. |
76
- | `404` | `pipelex.catalog.not_found` | An unknown method. The destination is judged first, so an unknown method in a destination the caller may not write to reads `pipelex.org.not_found`. |
79
+ | `404` | `pipelex.catalog.not_found` | An unknown method, or another organization's method that is not public. The destination is judged first, so an unknown method in a destination the caller may not write to reads `pipelex.org.not_found`. |
77
80
  | `409` | `pipelex.catalog.being_deleted` | The original's erasure has started. |
78
81
  | `409` | `pipelex.catalog.update_conflict` | The draft moved since `expected_draft_updated_at`; `location.field` names that member. Read the method again and copy with the fresh token. |
79
82
  | `409` | | The method's organization holds no record of the draft's tree, or no longer holds every file of it: its `domain` is `runtime` and its `retry` `no`, a stored record contradicting itself that neither the caller nor a retry can mend. No registered code names this refusal yet; it is not the `request.idempotency_conflict` of an idempotency key reused with other arguments (below). |
80
83
  | `413` | `request.too_large` | A copied example, or the copy's row, does not fit in one stored item. The row keeps room for the summary a publish adds, so a `name` that would leave none is refused. |
81
- | `422` | `request.invalid` | A body that does not validate, such as a name holding a lone surrogate. |
84
+ | `422` | `request.invalid` | A body that does not validate, such as a name holding a lone surrogate, or an `expected_draft_updated_at` sent to copy another organization's public method. |
82
85
  | `422` | `pipelex.catalog.copy_too_large` | The copy passes one of its bounds on the stored files it copies. Its problem's `limit` member, typed `MethodCopyLimit`, names the bound, `max_stored_files` or `max_stored_bytes`, its `value` and what the copy `reached`. Removing examples, or the files they hold, brings it under. |
83
86
  | `502` | `runtime.unavailable` | The platform could not read the caller's memberships. Its `retry` is `yes`. |
84
87
  | `500` | `pipelex.storage.not_configured` | The platform has no usable credentials for storage. Its `retry` is `no`. |
85
88
  | `503` | `pipelex.storage.unavailable` | Storage failed a read, a copy or a write. Its `retry` is `yes`. |
86
- | `503` | `runtime.unavailable` | The copy did not finish inside its deadline, a stored file it copied was written over meanwhile, or DynamoDB failed a read or a write. Its `retry` is `yes`. When DynamoDB failed both the write of the copy's row and the read that would confirm it, the `detail` names the copy's method id and says that a retry may make a second copy: the copy may stand in the destination, without the original's marker of it and without the destination's records of its files, so look for that id in the destination before copying again. |
89
+ | `503` | `runtime.unavailable` | The copy did not finish inside its deadline, a stored file it copied was written over meanwhile, or DynamoDB failed a read or a write. Its `retry` is `yes`. When DynamoDB failed both the write of the copy's row and the read that would confirm it, the `detail` names the copy's method id and says that a retry may make a second copy: the copy may stand in the destination, without the destination's records of its files, so look for that id in the destination before copying again. |
87
90
 
88
91
  `MethodErrorCode` names `pipelex.org.not_found` and `pipelex.catalog.copy_too_large` beside the other method codes ([errors.md](./errors.md#the-method-codes)).
89
92
 
@@ -115,14 +118,6 @@ try {
115
118
 
116
119
  Without a key, a copy whose answer was lost cannot be told from one that never ran, and sending it again may make a second copy. Even with one, two rare windows can make a second copy on a retry. A platform process can die after writing the copy and before storing its answer, a window the creation of a method shares. And the `503` whose `detail` names the copy released the key while the copy may stand, which is why it names the copy: look for that method id in the destination before copying again.
117
120
 
118
- ## The copies of a method
119
-
120
- The original keeps a marker of each copy made of it, and `listMethodCopies` pages them. A `MethodCopyMarker` holds `copy_method_id`, `destination_org_id`, `copied_by_user_id` and `copied_at`. The destination is named by its id alone, never by its name, so the listing tells no reader the name of an organization they do not belong to: a surface shows a destination's name from the reader's own memberships (`listMemberships`), and a copy in an organization the reader does not belong to stays an id. The rows come in the order of the copies' ids, which says nothing of when each was made; sort by `copied_at` to show them by date.
121
-
122
- Pass the page's `nextCursor` back as `cursor` to continue; `null` is the last page. A method never copied lists an empty page, as does a copy, which names its original through `copied_from` and has no copies of its own. A marker stays when its copy is erased, and the copy then reads as not found. The marker is written once the copy exists, and a failure to write it does not fail the copy, so a copy can, rarely, be missing from the list. The original's erasure and its discard delete its markers with it.
123
-
124
- The listing answers `404` `pipelex.catalog.not_found` for an unknown method, `409` `pipelex.catalog.being_deleted` while its erasure runs, and `422` `request.invalid` for a cursor it did not issue for that method or a `limit` outside 1 to 100. A page whose `items` is not an array or whose `next_cursor` is neither a string nor `null` is an answer the SDK cannot read.
125
-
126
121
  ## `copied_from` on a method
127
122
 
128
- Every read of a method, `getMethod` and the `method` of every write, carries `copied_from`: `{ org_id, method_id, draft_digest }` of the original on a copy, `null` on a method created afresh, and absent from a platform that predates copies. It is written once, when the copy is made, and never rewritten, so it names the draft the copy started from even after the original has changed or been erased; its `draft_digest` is `null` for a draft that held no tree. It holds opaque ids only, never a name, and the original's organization may be one the reader does not belong to. The list rows (`MethodSummary`) do not carry it.
123
+ Every read of a method, `getMethod` and the `method` of every write, carries `copied_from`: `{ org_id, method_id, draft_digest, version }` of the original on a copy, `version` naming the published version a copy of another organization's public method took and `null` for a copy of a draft, `null` on a method created afresh, and absent from a platform that predates copies. It is written once, when the copy is made, and never rewritten, so it names what the copy started from even after the original has changed or been erased: the draft, by its `draft_digest`, `null` for a draft that held no tree, or, for a copy of another organization's public method, the published `version`, its `draft_digest` `null`. It holds opaque ids only, never a name, and the original's organization may be one the reader does not belong to. The list rows (`MethodSummary`) do not carry it, and neither does another organization's public method read as a `PublicMethodDetail`.
@@ -147,7 +147,7 @@ const method = await client.createMethod({ name: "Receipts", tree });
147
147
 
148
148
  ## Reading a tree
149
149
 
150
- `getMethod(methodId)` answers a `MethodDetail`: the method, with `files`, the draft's tree listed in tree path order. `getMethodVersion(methodId, version)` answers a `MethodVersion`: the version's summary, with the same `files`. Each file is a `SavedMethodFile`:
150
+ `getMethod(methodId)` answers a `MethodDetail`: the method, with `files`, the draft's tree listed in tree path order; another organization's public method answers a `PublicMethodDetail`, its `files` that of its latest published version ([public-methods.md](./public-methods.md)). `getMethodVersion(methodId, version)` answers a `MethodVersion`: the version's summary, with the same `files`. Each file is a `SavedMethodFile`:
151
151
 
152
152
  | Field | Meaning |
153
153
  |---|---|
@@ -0,0 +1,97 @@
1
+ # Public methods and the template catalog
2
+
3
+ A stored method is `internal` by default: only the members of the organization that holds it see it. An admin of the platform can make one `public`, and every organization then reads, runs and copies its published versions, while every write stays the owner's. Users see the capability through the template catalog: the public methods of the organization the deployment names as its templates organization, which every organization reads the same. No route of this client makes a method public or writes the catalog's order; both are the platform's admin surface. The types are in `src/product-models.ts`. This page is the twin of the Python SDK's `docs/public-methods.md`.
4
+
5
+ ```ts
6
+ import { PipelexApiClient } from "@pipelex/sdk";
7
+
8
+ const client = new PipelexApiClient();
9
+ const page = await client.listMethodTemplates({ ordering: "default", category: "finance", limit: 8 });
10
+ for (const tab of page.categories) console.log(tab.slug, tab.label); // every category with a template
11
+ for (const template of page.items) {
12
+ // The thumbnail whose ratio is closest to a 2:1 card.
13
+ const card = template.thumbnails.reduce<(typeof template.thumbnails)[number] | undefined>(
14
+ (best, thumbnail) =>
15
+ best === undefined ||
16
+ Math.abs(thumbnail.width / thumbnail.height - 2) < Math.abs(best.width / best.height - 2)
17
+ ? thumbnail
18
+ : best,
19
+ undefined,
20
+ );
21
+ console.log(template.name, template.description, card?.url);
22
+ }
23
+ // From a web session, a template copies into a team the caller may change methods in, from its
24
+ // latest published version; an API key, which belongs to one organization, cannot copy.
25
+ const first = page.items[0];
26
+ if (first !== undefined) {
27
+ await client.copyMethod(first.method_id, { destination_org_id: "org_team" });
28
+ }
29
+ ```
30
+
31
+ ## The catalog
32
+
33
+ | Method | Route | Answer |
34
+ |---|---|---|
35
+ | `listMethodTemplates({ ordering?, category?, limit?, cursor? })` | `GET /v1/method-templates` | A `MethodTemplatePage`: `{ items, nextCursor, ordering, categories }`. |
36
+ | `iterateMethodTemplates({ ordering?, category?, limit? })` | the same, following the cursor | Every `MethodTemplate`, in the order served. |
37
+
38
+ **The order is the platform's.** An ordering is a named list an admin writes, such as `default` or one arm of a test. The templates it lists lead, in its order, and every other template follows by name, so a template appears as soon as it is public. `ordering` asks for one by name; an absent, unknown or malformed name serves `default`, and the page's `ordering` names the one served, `null` when there is none, the templates then listed by name. A client never sorts the cards: it keeps the order served.
39
+
40
+ **A category filters and keeps the order.** `category` takes a slug of the catalog's list, such as `human-resources`, and keeps that category's templates in the same order; a slug no template shows keeps none. Whatever the filter, the page's `categories` names, in the catalog's order, every category at least one template shows, each a `MethodCategory` with its `slug` and its English `label`, so a tab bar drawn from it never shows an empty tab. A client translating the labels translates by slug and falls back on the label.
41
+
42
+ **A card is the latest published version.** Each `MethodTemplate` carries `method_id`, `name`, `description` (`null` when it has none), `latest_version`, `categories` (slugs), `organization_name`, the display name of the organization holding it, and `thumbnails`, each a `MethodTemplateThumbnail`: the image's `path`, `media_type`, `width`, `height` and `sha256`, with a `url` signed by storage and its `expires_at`, about fifteen minutes after the read. The ratio is never declared: draw the thumbnail whose `width` and `height` come closest to your frame. The link is a bearer credential until it expires, so it stays out of logs and stored records, and an answer the SDK cannot read is thrown with its links redacted.
43
+
44
+ **Paging.** A page holds 50 templates by default and at most 200; a `limit` outside 1 to 200 is a `422` `request.invalid`. `nextCursor` resumes after the last template the page served, wherever the catalog now places it, so a template added or removed elsewhere between two pages neither repeats nor skips the ones around that point. The cursor holds neither the ordering nor the category: pass both again with it, as `iterateMethodTemplates` does. A cursor of another listing is a `422` `request.invalid` and one that does not decode a `422` `request.malformed`.
45
+
46
+ **Refusals.** `500` `runtime.misconfigured` when the templates organization the deployment names has no record or a link cannot be signed, and `503` `runtime.unavailable`, safe to retry. A deployment that names no templates organization serves an empty catalog.
47
+
48
+ ## Reading another organization's public method
49
+
50
+ A template is a public method, so its `method_id` opens on the method routes of every organization:
51
+
52
+ | Method | From another organization |
53
+ |---|---|
54
+ | `getMethod(id)` | A `PublicMethodDetail`: the latest published version, never the draft |
55
+ | `listMethodVersions(id)`, `getMethodVersion(id, n)` | The published versions, each without `published_by` |
56
+ | `start`, `execute`, `startAndWaitForResult` with `method_id` | The bare id or `mt_…@<n>` runs, belongs to and is billed to the caller's organization; `mt_…@draft` is `404` |
57
+ | `copyMethod(id, …)` | A copy of the latest published version (below) |
58
+ | `writeDraft`, `renameMethod`, `publishMethod`, `deleteMethod`, the examples | `404` `pipelex.catalog.not_found`: every write is the owner's |
59
+
60
+ **`getMethod` answers the owner's shape or the public one**, typed `MethodDetail | PublicMethodDetail`. The caller's own method reads whole, as before. Another organization's public method reads in a narrower shape: `method_id`, `org_id` (the organization holding it), `name`, `created_at`, `visibility`, and the latest published version's `description`, `categories`, `latest_version` and `latest_published`, with `files`, that version's tree, linked in the holding organization, which `writeMethodTree` writes as any listing. It carries nothing of the draft or of the owner's people: no `updated_at`, `draft_digest`, `input_data`, `pipe_output`, `created_by_user_id`, `copied_from` or `deletion_state`. The draft's token `updated_at` is what tells them apart, so narrow before reading the draft. An answer without `updated_at` that still carries a member only the owner's read has is a damaged owner read, and the SDK throws it as an answer it cannot read rather than pass it off as a public method:
61
+
62
+ ```ts
63
+ const method = await client.getMethod(methodId);
64
+ if ("updated_at" in method) {
65
+ await client.writeDraft(method.method_id, { contents, expected_updated_at: method.updated_at });
66
+ } else {
67
+ // Another organization's public method: read it, run it, copy it.
68
+ console.log(`version ${method.latest_version} of ${method.org_id}'s method`);
69
+ }
70
+ ```
71
+
72
+ **A version read leaves out its publisher.** `MethodVersionSummary.published_by` is optional for that reason: the publisher's user id no more crosses organizations than the method's `created_by_user_id` does.
73
+
74
+ **What reads as not found.** An unknown id, another organization's internal method, and a public one made internal again or being erased all answer `404` `pipelex.catalog.not_found`, alike. A method made internal is closed at once, even before the platform's index has caught up.
75
+
76
+ ## Copying a public method
77
+
78
+ `copyMethod` of another organization's public method copies from its **latest published version**, never its draft: that version's tree, description and categories, and the method's saved examples, but not the owner's form inputs. The copy's `copied_from` names the `version` it took, its `draft_digest` `null`. Leave `expected_draft_updated_at` out: the caller never saw that method's draft, and the field sent with it is refused with a `422` `request.invalid` naming it. The rest of the copy, its verdict and its refusals, are [method-copy.md](./method-copy.md)'s.
79
+
80
+ ## Visibility, categories and thumbnails on a method
81
+
82
+ - **`visibility`**, on every method read (`MethodData`, `MethodDetail`, `PublicMethodDetail`): `internal` or `public`, read open, so a value a later platform adds arrives as its raw string. Absent from a platform that predates it, which means `internal`. The list rows (`MethodSummary`) do not carry it.
83
+ - **`categories`**, on a method and on each version summary: the slugs the manifest declares under `[package]` that are on the catalog's list, in their declared order. The platform ignores a value it does not know, so a typo reads as no category.
84
+ - **`thumbnails`**, on each version summary: the valid images directly under the tree's `thumbnails/` folder, each a `MethodThumbnail` (`path`, `media_type`, `width`, `height`, `sha256`), measured by the publish. A PNG, JPEG or WebP of at least 800 by 400 pixels is valid; an invalid image is simply not recorded.
85
+ - **`description`**: the manifest's `description`, else the primary bundle's top-level `description`, else its main pipe's, a blank one counting as none at each step.
86
+
87
+ ## Publishing a public method
88
+
89
+ A public method's publish is held to more than any draft's, so that a template's card never loses its picture and every organization can run what it reads. On top of `invalid` and `not_runnable`, its `refused` verdict may carry:
90
+
91
+ | `reason` | When |
92
+ |---|---|
93
+ | `storage_reference` | A `.mthds` file of the draft names a `pipelex-storage://` file, which other organizations cannot read. |
94
+ | `thumbnail_missing` | The draft's tree holds no file directly under `thumbnails/`, or none was read within the publish's time; a retry may read them in time. |
95
+ | `thumbnail_invalid` | No file directly under `thumbnails/` is a valid thumbnail. |
96
+
97
+ Each is a verdict, a `200` with a `message` for a person and the runner's valid `validation`, never a thrown error. `MethodPublishRefused.reason` is read open, typed `MethodPublishRefusalReason | (string & {})`: a reason a later platform adds arrives as its raw string, still a verdict, so a client shows its `message` when it does not handle the reason. An internal method's publish never meets the reasons of this table. A method made public while its publish ran is a `503` with nothing written, safe to retry.
package/docs/webhooks.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Caller webhooks
2
2
 
3
- A run can call webhooks of its caller once it has ended, so the caller learns of the end without polling. The run registers the URLs when it starts, in `callback_urls`; when it ends, the runtime sends each URL one signed `POST` carrying a `run.finished` event; the caller's receiver verifies the call, then fetches the results by the run's id. The contract is the spec `conformance/specs/pipelex-caller-webhooks.md`, in the `conformance` repository, which both the hosted plane and a self-hosted runner honour. This page covers the four parts the SDK takes: registering the URLs, verifying a call (`verifyWebhook`), reading the hosted delivery log, and reading or rotating the organization's signing secret. The Python SDK's twin is [`python/docs/webhooks.md`](../../python/docs/webhooks.md).
3
+ A run can call webhooks of its caller once it has ended, so the caller learns of the end without polling. The run registers the URLs when it starts, in `callback_urls`; when it ends, the runtime sends each URL one signed `POST` carrying a `run.finished` event; the caller's receiver verifies the call, then fetches the results by the run's id. The contract is the spec `conformance/specs/pipelex-caller-webhooks.md`, in the `conformance` repository, which both the hosted plane and a self-hosted runner honour. This page covers the four parts the SDK takes: registering the URLs, verifying a call (`verifyWebhook`), reading the hosted delivery log, and reading or rotating the organization's signing secret. The Python SDK's twin is [`python/docs/webhooks.md`](https://github.com/Pipelex/pipelex-sdk/blob/main/python/docs/webhooks.md).
4
4
 
5
5
  ## Registering the webhooks — `callback_urls`
6
6
 
@@ -98,8 +98,19 @@ The event never carries the run's results: fetch them with `getRunResult(event.d
98
98
  The hosted plane records each delivery, one per URL a run registered, with every attempt. Any member of the organization reads them, by web session or API key:
99
99
 
100
100
  - `listRunWebhookDeliveries(runId)` — `GET /v1/runs/{id}/webhook-deliveries`, a `WebhookDeliveryList` whose `deliveries` are `WebhookDeliverySummary` rows in the order of the run's URLs, none for a run that registered none or has not ended. Each has its `delivery_id` (the receiver's `webhook-id`), its `url_index` and redacted `url`, its `state` (`pending` on its retry schedule, `delivered` once a receiver answered `2xx`, `failed` past its last attempt) and its `attempts`, each with when it was sent, the HTTP `status_code` the receiver answered, or the `failure` that kept an answer from coming (`timeout`, `connection`, `tls`, `refused_address`), and its `duration_ms`. No response body is kept.
101
- - `getRunWebhookDelivery(runId, deliveryId)` — one `WebhookDeliveryDetail`, the summary and the `event` its latest attempt sent.
102
- - `replayRunWebhookDelivery(runId, deliveryId)` — sends a settled delivery again: one new attempt under the same `webhook-id`, with the same `data`, the next `attempt`, a fresh timestamp and the signatures of the secrets live then. It answers `202` with a `WebhookReplayAccepted` naming the attempt; a replay asked again before it is sent is that same attempt. A delivery still `pending` is refused `409` `pipelex.org.not_manageable` with the reason `webhook_delivery_pending`, and one holding the most attempts a delivery holds with `webhook_delivery_attempts_exhausted`; past the organization's replay rate the platform answers `429` `account.rate_limited` with its `retry_after`.
101
+ - `getRunWebhookDelivery(runId, deliveryId)` — one `WebhookDeliveryDetail`, the summary and its `event`: the body its latest attempt sent, or, before any attempt, the body its first attempt will send. Every attempt and replay of the delivery sends that body, only its `attempt` changing, so the `event` matches what a receiver logged only once an attempt was made: it then equals, as JSON, the body that attempt brought the receiver. Compare the two as parsed JSON values, never as text, since the read promises the same value, not the same bytes.
102
+ - `replayRunWebhookDelivery(runId, deliveryId, { idempotencyKey? })` — sends a settled delivery again: one new attempt under the same `webhook-id`, with the same `data`, the next `attempt`, a fresh timestamp and the signatures of the secrets live then. It answers `202` with a `WebhookReplayAccepted` naming the attempt; a replay asked again before it is sent is that same attempt. A delivery still `pending` is refused `409` `pipelex.org.not_manageable` with the reason `webhook_delivery_pending`, and one holding the most attempts a delivery holds with `webhook_delivery_attempts_exhausted`; past the organization's replay rate the platform answers `429` `account.rate_limited` with its `retry_after`.
103
+
104
+ ### Retrying a replay — `idempotencyKey`
105
+
106
+ `replayRunWebhookDelivery`'s last argument takes an `idempotencyKey`, sent as the `Idempotency-Key` header of the replay and not sent at all when absent or `null`. The platform stores the replay's answer under the key, so the same replay sent again under the same key gets the stored answer, naming the same attempt, and the delivery is not sent twice: a replay whose answer was lost, as on a client timeout, can be sent again safely. Generate one key per replay you mean to ask for (a `crypto.randomUUID()` is a good one), and keep it across that replay's retries:
107
+
108
+ ```ts
109
+ const idempotencyKey = crypto.randomUUID();
110
+ const accepted = await client.replayRunWebhookDelivery(runId, deliveryId, { idempotencyKey });
111
+ ```
112
+
113
+ A key reused for another request is refused `409` `request.idempotency_conflict`, whose `retry` is `no`, and a key whose earlier request is still being handled `409` `request.idempotency_key_in_use`, whose `retry` is `yes`: send it again under the same key after its `retryAfter`. A key is 1 to 255 visible ASCII characters, `!` to `~`; anything else, an empty string included, is a `RequestArgumentError` naming `idempotency_key` before anything is sent, the rule a start's key is held to ([errors.md](./errors.md#a-starts-idempotency-key--idempotencykey)).
103
114
 
104
115
  The states and failures are read open: one this SDK does not name arrives as its raw string.
105
116
 
@@ -116,4 +127,4 @@ Both refuse another role with `403` `auth.forbidden` whose `params.requirement`
116
127
 
117
128
  The contract's known-answer vectors, `tests/caller_webhooks/fixtures/signature-vectors.json` in the `conformance` repository, are vendored byte for byte at `tests/fixtures/caller-webhooks/signature-vectors.json`, with `tests/fixtures/caller-webhooks/vendored.json` recording the commit it was copied at and its SHA-256. `tests/webhooks.test.ts` fails when the copy no longer has that digest, verifies every verification case at its `receiver_clock` and compares its verdict and refusal, and verifies every signing case under each secret that signed it. The shared case file `tests/fixtures/webhook-events.json` adds what the vectors leave to each SDK: the problem of each refusal, what each verified body reads as (the hosted and self-hosted shapes, an unknown type, the unreadable bodies) and the secrets refused.
118
129
 
119
- All three files are shared with the Python SDK the way every case file is ([`docs/layout.md`](../../docs/layout.md)): the source is here, `scripts/shared-files.mjs` declares the copies under `python/tests/fixtures/`, and `make check-shared-files` fails on a stale copy. To refresh the vectors, copy the file from the conformance repository at the commit that last changed it, update the commit and the digest in `vendored.json`, and run `make shared-files` at the repository's root.
130
+ All three files are shared with the Python SDK the way every case file is ([`docs/layout.md`](https://github.com/Pipelex/pipelex-sdk/blob/main/docs/layout.md) at the repository's root): the source is here, `scripts/shared-files.mjs` declares the copies under `python/tests/fixtures/`, and `make check-shared-files` fails on a stale copy. To refresh the vectors, copy the file from the conformance repository at the commit that last changed it, update the commit and the digest in `vendored.json`, and run `make shared-files` at the repository's root.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipelex/sdk",
3
- "version": "0.59.0",
3
+ "version": "0.61.0",
4
4
  "description": "TypeScript SDK for the Pipelex hosted API — execute methods, manage runs, and call the product surface.",
5
5
  "license": "MIT",
6
6
  "repository": {