@oneie/sdk 0.15.1 → 0.16.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.
package/dist/receivers.js CHANGED
@@ -226,17 +226,45 @@ export const RECEIVERS = {
226
226
  request: z.object({ workspace: z.string() }),
227
227
  response: ok, effect: "ask", auth: "manage_workspace",
228
228
  }),
229
+ /**
230
+ * TWO PATHS, ONE RECEIVER — and the second one is why `slug`/`parent` exist.
231
+ *
232
+ * Bare (`name` + `type`, no `groupSlug`, no `parent`): unchanged legacy behaviour —
233
+ * a random gid in D1 `world_groups`. That is what a campaign or a collection
234
+ * is, and every existing caller stays on it byte for byte.
235
+ *
236
+ * Substrate (`groupSlug` + `parent`, BOTH required together): the real group unit —
237
+ * a TypeDB `group`, a `hierarchy` edge to the parent, an owner `membership`
238
+ * for the attested caller, and the D1 `owners` row — written by `createGroup`
239
+ * in `one.ie/web/src/lib/group-unit.ts`. Measured 2026-09-16: no shipped
240
+ * receiver could write a group with a PARENT and a chosen slug, so a personal
241
+ * group under a world root had to be typed by hand.
242
+ *
243
+ * The gid's SHAPE differs by path and that is the contract change callers
244
+ * notice: `generateTraceId()` on the bare path, deterministic `group:<slug>`
245
+ * on the substrate one.
246
+ *
247
+ * `parent` NAMES a target; it never authorizes one. The handler walks the
248
+ * caller's control of the parent workspace and refuses otherwise, and the
249
+ * owner it writes is the ATTESTED caller — never a body field.
250
+ */
229
251
  "world:create-group": receiver({
230
252
  receiver: "world:create-group",
231
253
  surfaces: { mcp: true },
232
- summary: "Create a group in the caller's workspace",
254
+ summary: "Create a group — a D1 campaign/collection row, or (with groupSlug+parent) a substrate group under a parent the caller controls",
233
255
  request: z.object({
234
256
  name: z.string(),
235
257
  // "group" is the D1 group_type for campaigns/collections; absent from the TypeDB enum
236
- type: GroupEntitySchema.shape["group-type"].or(z.literal("group")),
258
+ type: GroupEntitySchema.shape["group-type"].or(z.literal("group"))
259
+ .describe("One of the nine group-type values in schema/one.tql, or the D1-only \"group\". The substrate path (groupSlug+parent) accepts ONLY the nine — TypeDB's @values constraint refuses anything else and that write would fail silently."),
260
+ groupSlug: z.string().optional()
261
+ .describe("Bare slug for a SUBSTRATE group; the gid is always `group:<groupSlug>`. Requires `parent` — a slug with no parent would mint an unparented root workspace and is refused. NOT named `slug`: on the service lane `/api/ask` reads `payload.slug ?? payload.workspace` as the workspace the caller NOMINATES for itself, so a field called `slug` here would silently re-identify the caller as the group it is trying to create."),
262
+ parent: z.string().optional()
263
+ .describe("gid of the parent group, e.g. \"group:one\". Requires `groupSlug`. It NAMES a target and never authorizes one: the caller must control the parent workspace or the call is refused."),
237
264
  tags: z.array(z.string()).optional(),
238
265
  }),
239
- response: z.object({ gid: z.string() }), effect: "ask", auth: "manage_groups",
266
+ response: z.object({ gid: z.string().describe("`group:<groupSlug>` on the substrate path; a random trace id on the bare D1 path") }),
267
+ effect: "ask", auth: "manage_groups",
240
268
  }),
241
269
  "world:update-group": receiver({
242
270
  receiver: "world:update-group",
@@ -376,6 +404,7 @@ export const RECEIVERS = {
376
404
  nickname: z.string().optional(),
377
405
  title: z.string().optional(),
378
406
  avatar: z.string().optional(),
407
+ cover: z.string().optional(),
379
408
  bio: z.string().optional(),
380
409
  // contact
381
410
  email: z.string().optional(),
@@ -985,7 +1014,12 @@ export const RECEIVERS = {
985
1014
  // with the TS type by hand.
986
1015
  meta: z.record(z.string(), z.unknown()).optional(),
987
1016
  }),
988
- response: z.object({ ok: z.boolean(), taskId: z.string().optional(), matched: z.number().optional(), actors: z.array(z.string()).optional() }),
1017
+ response: z.object({
1018
+ ok: z.boolean(),
1019
+ taskId: z.string().optional(),
1020
+ matched: z.number().optional().describe("REACH, NEVER DELIVERY. This is `actors.length` from the fan-out (`resolvers/subscriptions.ts:1245`) — how many staked actors the tags MATCHED, not how many received it, read it, or acted on it. Each matched actor got an inbox row; whether any of them moved the task is one `tasks:claim` away and this number cannot see it. A digest that quotes this as 'N people are on it' is reporting a fan-out as a delivery."),
1021
+ actors: z.array(z.string()).optional().describe("The actor ids the fan-out reached — the same reach `matched` counts, itemised. Reaching is not acting."),
1022
+ }),
989
1023
  effect: "ask",
990
1024
  }),
991
1025
  "tasks:mine": receiver({
@@ -2058,7 +2092,7 @@ export const RECEIVERS = {
2058
2092
  "tasks:bulk": receiver({
2059
2093
  receiver: "tasks:bulk",
2060
2094
  surfaces: { mcp: true },
2061
- summary: "Plan and act on many tasks in ONE request. Three modes, combinable: `creates` files up to 25 new tasks (with `ref` handles so rows in the same call can parent/block each other — a whole plan tree in one call); `edits` changes up to 25 named rows (title/status/priority/notes/assignee/tags/dates/dependencies/parent/comment — `parent` MOVES a row under another, the one door that writes `containment` after birth, and an empty string makes it a root again); `where`+`set` applies ONE edit to every row a tasks:board filter matches (dryRun defaults TRUE — you see the matched tids before anything moves; applies 25 per call and returns `nextCursor` to continue). Returns a receipt PER ROW (ok, per-field reason, retryable) plus total/attempted/applied/failed — a row never attempted says so by name, so a partial run can never read as a success.",
2095
+ summary: "Plan and act on many tasks in ONE request. Three modes, combinable: `creates` files up to 25 new tasks (with `ref` handles so rows in the same call can parent/block each other — a whole plan tree in one call); `edits` changes up to 25 named rows (title/status/priority/notes/assignee/tags/dates/dependencies/parent/comment — `parent` MOVES a row under another, the one door that writes `containment` after birth, and an empty string makes it a root again); `where`+`set` applies ONE edit to every row a tasks:board filter matches (dryRun defaults TRUE — the preview returns the matched ROWS themselves in `rows`, not just their tids, so you read the ≤25 rows you are about to change and then re-send the identical call with dryRun:false; applies 25 per call and returns `nextCursor` to continue). Returns a receipt PER ROW (ok, per-field reason, retryable) plus total/attempted/applied/failed — a row never attempted says so by name, so a partial run can never read as a success.",
2062
2096
  request: z.object({
2063
2097
  edits: z.array(z.object({
2064
2098
  tid: z.string(),
@@ -2107,7 +2141,7 @@ export const RECEIVERS = {
2107
2141
  removeTags: z.array(z.string()).optional(),
2108
2142
  dueAt: z.string().nullable().optional(),
2109
2143
  comment: z.string().optional(),
2110
- }).optional().describe("The ONE edit applied to every `where` match. title/notes are deliberately absent — those are per-row."),
2144
+ }).optional().describe("The ONE edit applied to every `where` match. title/notes are deliberately absent — those are per-row. TWO ROUTES, AND THIS IS THE NARROWER ONE. `assignee` here is the DIRECT route: you decide the owner, for work whose owner is not in doubt. The DEFAULT is the other route — `tasks:announce` (or `signal(\"world\", {tags})`) walks the weighted tag→receiver paths to whoever STAKED those tags, marks the delivery, and the next route is smarter for it; the CEO is an observer there, not a bottleneck. Prefer the stake route and keep this one for the unambiguous case. And when you read the announce back: its `matched` is reach, never delivery."),
2111
2145
  dryRun: z.boolean().optional().describe("For `where`: defaults TRUE. Pass false to apply. Ignored by edits/creates."),
2112
2146
  cursor: z.string().optional().describe("For `where`: the nextCursor from the previous call"),
2113
2147
  workspace: z.string().optional().describe("Workspace to act in, for every row. Honoured only if you are staff or control it."),
@@ -2125,8 +2159,14 @@ export const RECEIVERS = {
2125
2159
  error: z.string().optional(),
2126
2160
  detail: z.string().optional(),
2127
2161
  created: z.record(z.string(), z.string()).optional().describe("ref → tid for every `creates` row that landed"),
2128
- matched: z.number().optional().describe("`where`: rows the filter matched in total, across all pages"),
2162
+ matched: z.number().optional().describe("`where`: rows the filter matched in total, across all pages. EXACT — unless `truncated` is present, and then it is a FLOOR. Quote it as 'at least N'."),
2163
+ truncated: z.object({
2164
+ rows: z.number().optional().describe("The board snapshot hit its row budget; this many unique rows were read. `matched` is a FLOOR — rows exist that the filter never saw, so the set you are previewing is a subset of the set you asked for."),
2165
+ edges: z.boolean().optional().describe("The dependency branch was capped. `where:{ready:true}` is computed from `blockedBy`, so a row can match as ready while it is in fact blocked."),
2166
+ tags: z.number().optional().describe("The tag branch was capped; this many rows have uncertain tags. A `tags`/`anyTags` filter may have MISSED rows, and the `tags` on the rows returned above may be short."),
2167
+ }).optional().describe("`where`: the budget that bit, named — the same word and the same shape tasks:board serves, because it is the same snapshot. Three budgets, independent, each key present only when THAT one bit. ABSENT MEANS NOTHING WAS CUT — absent and `false` are different answers, so branch on the key's presence and never on its value. This is the field to read rather than the prose in `detail`: `detail` is for a human reading a log, and on an aborted run it is overwritten by the abort reason, while this survives. A receiver that caps, pages or samples names the budget that bit, in the response, every time."),
2129
2168
  matchedIds: z.array(z.string()).optional().describe("`where`: the tids this call covers (the ones it would apply, on dryRun)"),
2169
+ rows: z.array(z.record(z.string(), z.unknown())).optional().describe("`where`, DRY RUN ONLY: the matched rows THEMSELVES — `matchedIds` with its contents attached. Same rows, same order (rows[i].tid === matchedIds[i], element for element), same shape tasks:board serves: tid · name · status · priority · tags · assignee · workspace · parent · blockedBy · dueAt · startAt · createdAt · closedAt · updatedAt · notes. This is what makes the dry run a READ you can judge instead of a count you must take on faith: you see the ≤25 rows you are about to change — their owners, their blockers, their tags — and then re-send the IDENTICAL call with dryRun:false. One filter expression, not two that can disagree about what the set is. Costs nothing: these are the rows the match was computed from, already in hand. ABSENT on an apply — `results` carries a receipt per tid there, and a second copy of the rows is the larger answer to the smaller question. TWO TRAPS. (1) `tags` is the BARE lens — namespaced words (`plan:…`, `cycle:…`, `workspace:…`) and the `@slug` assignee tag are stripped, and `assignee`/`workspace` are DERIVED from the stripped ones. `tags: []` therefore means NO ROUTING WORDS, never 'no tags on this row': measured 2026-09-15, rows carrying six tags each report `tags: []`. (2) A row here is at most `asOf` stale (the 60s board memo) and describes the row BEFORE `set` — never read it back as the result of the write."),
2130
2170
  dryRun: z.boolean().optional(),
2131
2171
  nextCursor: z.string().optional().describe("`where`: present iff more matches remain — resend the same call with this cursor"),
2132
2172
  results: z.array(z.object({
@@ -4379,7 +4419,17 @@ export const RECEIVERS = {
4379
4419
  receiver: "web:crawl",
4380
4420
  summary: "Fetch a URL and extract domain, name, description, and readable text (public)",
4381
4421
  request: z.object({ url: z.string() }),
4382
- response: z.object({ ok: z.boolean(), url: z.string().optional(), domain: z.string().optional(), name: z.string().optional(), description: z.string().optional(), text: z.string().optional(), error: z.string().optional() }),
4422
+ response: z.object({
4423
+ ok: z.boolean(),
4424
+ url: z.string().optional().describe("The URL that ANSWERED, not the one asked for — a crawl of an apex that 301s to www reports the www URL here"),
4425
+ domain: z.string().optional(),
4426
+ name: z.string().optional(),
4427
+ description: z.string().optional(),
4428
+ text: z.string().optional(),
4429
+ error: z.string().optional().describe("NAMED, never a single catch-all. invalid_url · https_required · dns_unresolved · blocked_host · redirect_no_location · redirect_invalid_url · redirect_blocked:<ssrf reason> · too_many_redirects · fetch_failed:<status> · not_html · timeout · fetch_error. Until 2026-09-15 every network cause collapsed into `fetch_error`, so a 301 apex->www and an origin refusing the fetch outright were indistinguishable — read `detail` beside this."),
4430
+ detail: z.string().optional().describe("The underlying cause in words, ≤200 chars — the exception class and message, the blocked hop, or the content-type that was refused. Present on most errors, never on success."),
4431
+ redirects: z.number().optional().describe("Hops followed before the answer. Redirects are followed MANUALLY, at most 4, and the SSRF guard re-runs on every hop — a public URL that redirects to a private address is refused at that hop with redirect_blocked."),
4432
+ }),
4383
4433
  effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
4384
4434
  }),
4385
4435
  // ── people / companies / conversation — handlers in resolvers/groups.ts ────────
@@ -6054,6 +6104,143 @@ export const RECEIVERS = {
6054
6104
  settles: "none",
6055
6105
  examples: [{ href: "/u/one/in", reason: "agent" }],
6056
6106
  }),
6107
+ // ── media:* ─────────────────────────────────────────────────────────────────
6108
+ // THE WORKSPACE MEDIA LIBRARY, for an agent.
6109
+ //
6110
+ // WHY. Measured 2026-09-16 over this file: 362 receivers, and not one touched a
6111
+ // stored asset. `video:*` (14) is rooms, sessions and recordings — a call, never a
6112
+ // file. So the media console at `/u/<slug>/media` was HTTP-only: a human could list,
6113
+ // upload and generate; an agent could not reach any of it, from any surface.
6114
+ //
6115
+ // THE ONE AUTHORITY RULE, and it is not either HTTP door's. `workspace` is a SCOPE
6116
+ // REQUEST on all three — never an identity, never a default. It is honoured only
6117
+ // through the owner-tree walk, and a request the caller cannot clear is REFUSED BY
6118
+ // NAME. The three HTTP write doors (`api/media/generate.ts:35`, storefront
6119
+ // image-upload/image-generate) instead derive from `locals.slug` and IGNORE the
6120
+ // workspace handed to them, so an agency uploading at `/u/client/media` files into
6121
+ // the AGENCY's library while the grid shows the CLIENT's — and answers 200. These
6122
+ // receivers do not inherit that: a silent downgrade is a success you cannot audit.
6123
+ // Resolver: `one.ie/web/src/lib/resolvers/media.ts`.
6124
+ "media:list": receiver({
6125
+ receiver: "media:list",
6126
+ surfaces: { mcp: true, chat: true },
6127
+ summary: "Every image and video a workspace owns, as typed rows — ONE R2 scan of `{workspace}/` filtered to the seven prefixes the reader will actually serve (media, chat, ads, products, pages, brand, courses), so generated chat images and ad creative are visible, not just hand-uploads. Carries `scanned` (how much of the page was not media), `truncated` (the budget that bit) and `nextCursor`. Each row's `url` is the one door that reads bytes.",
6128
+ request: z.object({
6129
+ workspace: z.string().optional().describe("Group slug. Omit for your own. Honoured only if you may read it."),
6130
+ kind: z.enum(["image", "video"]).optional().describe("Omit for both. Filters the rows on this page; it does not change what is scanned."),
6131
+ prefix: z.enum(["media", "chat", "ads", "products", "pages", "brand", "courses"]).optional()
6132
+ .describe("Only assets under this top-level namespace. media = hand-uploads and library generations; chat = images/videos an agent made in a conversation; ads = creative; products/pages/courses/brand = assets filed beside the thing they illustrate."),
6133
+ limit: z.number().int().min(1).max(1000).optional()
6134
+ .describe("R2 objects to SCAN on this page BEFORE prefix/kind filtering — not a row count. Default 500, max 1000 (R2's own ceiling). A workspace whose bucket is mostly non-media can return few rows from a full scan; that is what `scanned` reports."),
6135
+ cursor: z.string().optional().describe("The nextCursor from the previous page."),
6136
+ }),
6137
+ response: z.object({
6138
+ ok: z.boolean(),
6139
+ workspace: z.string().optional().describe("The workspace actually read — always the one you asked for, because a request you may not read is refused rather than downgraded."),
6140
+ items: z.array(z.object({
6141
+ key: z.string().describe("The R2 object key, `{workspace}/{prefix}/…` — the handle every other media receiver takes."),
6142
+ url: z.string().describe("`/api/product-image/{workspace}/{path}` — THE reader. Range-forwarding, prefix-allowlisted, refuses SVG by stored contentType."),
6143
+ kind: z.enum(["image", "video"]),
6144
+ prefix: z.string().describe("Top-level namespace, no trailing slash."),
6145
+ contentType: z.string(),
6146
+ size: z.number().nullable().describe("Bytes, or null when R2 did not report it."),
6147
+ uploaded: z.string().nullable().describe("ISO time, or null."),
6148
+ })).optional(),
6149
+ total: z.number().optional()
6150
+ .describe("Media rows in THIS answer, after the kind/prefix filter. NOT a pre-paging count — an R2 scan cannot know one without walking every page. A FLOOR whenever `truncated` is present: say 'at least N'."),
6151
+ scanned: z.number().optional()
6152
+ .describe("Objects R2 listed on this page BEFORE filtering. `scanned` far above `total` means most of the page was not media — follow the cursor rather than concluding the workspace is empty."),
6153
+ byPrefix: z.record(z.string(), z.number()).optional().describe("What each namespace contributed to THIS page — never a workspace total."),
6154
+ prefixes: z.array(z.string()).optional().describe("Every prefix this door will ever return, so a caller can offer the filter without hardcoding the list."),
6155
+ truncated: z.object({
6156
+ rows: z.number().optional(),
6157
+ scanned: z.number().optional(),
6158
+ limit: z.number().optional().describe("The scan budget that bit."),
6159
+ reason: z.string().optional(),
6160
+ }).optional().describe("ABSENT IS NOT FALSE — a complete answer carries no such key. Present = R2 reported more objects under this workspace, `total` is a floor, and you must say so before planning on it."),
6161
+ nextCursor: z.string().optional().describe("Present iff `truncated` is. Follow it or you have not seen the bucket."),
6162
+ error: z.string().optional(),
6163
+ }),
6164
+ effect: "ask", cost: "free", idempotent: true, reversible: true, settles: "none", auth: "member",
6165
+ examples: [
6166
+ { workspace: "one" },
6167
+ { workspace: "one", kind: "video" },
6168
+ { prefix: "ads", limit: 1000 },
6169
+ ],
6170
+ }),
6171
+ // media:upload — bytes in, one key out, under `{workspace}/media/`.
6172
+ //
6173
+ // TWO SHAPES BECAUSE AN AGENT HAS NEITHER A FILE PICKER NOR A MULTIPART BODY. The
6174
+ // HTTP door takes multipart and nothing else, so this is not a wrapper: the R2 write
6175
+ // and the magic-byte sniff are reimplemented at the same 5 MiB cap.
6176
+ //
6177
+ // `url` IS A NEW OUTBOUND-FETCH SURFACE and is treated as one — the shared SSRF guard
6178
+ // (shape + DNS, fail-closed) runs BEFORE the fetch, `redirect:'error'` so a public
6179
+ // host cannot 302 the worker onto a metadata address, and the body is read against a
6180
+ // hard byte budget rather than trusting `content-length`.
6181
+ "media:upload": receiver({
6182
+ receiver: "media:upload",
6183
+ surfaces: { mcp: true, chat: true },
6184
+ summary: "Put ONE raster image into a workspace's media library and get back the key and the url that serves it. Name exactly one source: `base64` (bytes you hold) or `url` (a public https address the server fetches under an SSRF guard, no redirects, 5 MiB hard cap). The stored content type comes from the MAGIC BYTES, never from what you declare — SVG is refused deliberately, and video is not accepted here.",
6185
+ request: z.object({
6186
+ workspace: z.string().optional().describe("Group slug. Omit for your own. Honoured only if you may read it."),
6187
+ url: z.string().optional().describe("Public https address to fetch. Alternative to `base64`, never both. Private, loopback, link-local and CGNAT addresses are refused, as is any redirect."),
6188
+ base64: z.string().optional().describe("The image bytes, base64. A `data:` prefix is accepted and stripped. Alternative to `url`, never both."),
6189
+ filename: z.string().optional().describe("A LABEL for the key, not a path — separators are stripped and the extension always comes from the sniffed bytes, so `../` and a `.svg` suffix are both inert. Omit and the key is a bare uuid."),
6190
+ contentType: z.string().optional().describe("A HINT only. The stored value is decided by the magic bytes, because the public reader serves the stored value verbatim under nosniff."),
6191
+ }),
6192
+ response: z.object({
6193
+ ok: z.boolean(),
6194
+ workspace: z.string().optional(),
6195
+ key: z.string().optional().describe("`{workspace}/media/…` — the handle media:list returns."),
6196
+ url: z.string().optional().describe("`/api/product-image/{workspace}/{path}` — THE reader."),
6197
+ kind: z.literal("image").optional().describe("Always image. Video is a 5 MiB-cap and multipart-shape decision this door does not make; PUT /api/media/video-upload is the door that does."),
6198
+ contentType: z.string().optional().describe("Sniffed, not declared."),
6199
+ size: z.number().optional().describe("Bytes actually stored."),
6200
+ error: z.string().optional().describe("Named: forbidden · too_large · unsupported_type · blocked_url · fetch_failed · storage_error. A refusal is never a downgrade to your own workspace."),
6201
+ }),
6202
+ effect: "ask", cost: "free", idempotent: false, reversible: false, settles: "none", auth: "member",
6203
+ examples: [
6204
+ { workspace: "one", url: "https://example.com/logo.png", filename: "logo" },
6205
+ { base64: "iVBORw0KGgo…" },
6206
+ ],
6207
+ }),
6208
+ // media:generate — one image from a prompt, straight into `{workspace}/media/`.
6209
+ //
6210
+ // DELIBERATELY NOT `surfaces.chat`. Chat already ships a curated `generate_image`
6211
+ // (`channels/src/aitools.ts:376`): three providers, aspect/width/height, per-call
6212
+ // billing and a spend-shaped approval gate — strictly more capable there, and it
6213
+ // files under `{ws}/chat/`. Flagging this for chat too would offer a model TWO image
6214
+ // generators with different prefixes and no way to choose between them, and the names
6215
+ // differ (`generate_image` vs `media_generate`) so the curated-wins precedence in
6216
+ // `channels/src/tools/from-registry.ts` would not suppress it. MCP has no such tool;
6217
+ // that is where this one earns its place.
6218
+ //
6219
+ // `context` is a PROMPT QUALIFIER, not a key prefix. A library is THE prefix, not a
6220
+ // context — the distinction `api/media/generate.ts`'s header argues out against the
6221
+ // contextual storefront door. Nothing this receiver writes lands outside `media/`.
6222
+ "media:generate": receiver({
6223
+ receiver: "media:generate",
6224
+ surfaces: { mcp: true },
6225
+ summary: "Generate ONE image from a prompt and file it in the workspace's media library. Keyless — flux-1-schnell on the Cloudflare AI binding. Returns the key and the url that serves it. Unlike the storefront generator this takes no destination: it always writes `{workspace}/media/`, because a library is a prefix and not a context.",
6226
+ request: z.object({
6227
+ workspace: z.string().optional().describe("Group slug. Omit for your own. Honoured only if you may read it."),
6228
+ prompt: z.string().min(1).describe("What to draw. Truncated at 2000 chars after `context` is folded in."),
6229
+ context: z.string().optional().describe("Extra prompt material — brand, palette, subject. A QUALIFIER appended to the prompt, NOT a destination: it never changes the key prefix."),
6230
+ }),
6231
+ response: z.object({
6232
+ ok: z.boolean(),
6233
+ workspace: z.string().optional(),
6234
+ key: z.string().optional().describe("`{workspace}/media/{uuid}.{ext}` — a uuid, never a timestamp: the url it names is served by a PUBLIC reader, so the name is the only thing standing between the object and a stranger."),
6235
+ url: z.string().optional().describe("`/api/product-image/{workspace}/{path}` — THE reader."),
6236
+ kind: z.literal("image").optional(),
6237
+ contentType: z.string().optional().describe("Sniffed from the returned bytes — flux returns JPEG today and returned PNG when the storefront door was written."),
6238
+ size: z.number().optional(),
6239
+ error: z.string().optional().describe("Named: forbidden · not_configured (no AI or CONTENT binding) · generation_failed · storage_error."),
6240
+ }),
6241
+ effect: "ask", cost: "variable", idempotent: false, reversible: false, settles: "none", auth: "member",
6242
+ examples: [{ workspace: "one", prompt: "a wide banner of a quiet harbour at dawn, muted palette" }],
6243
+ }),
6057
6244
  };
6058
6245
  /**
6059
6246
  * RECIPES — the four agent journeys as typed, ordered receiver sequences (C6).