openvibers/openvibe.contracts Commits

v0.33.1: tools.tool.read is active (Tools serves GET /api/v1/tools[/:id[/schema]] on the gateway and every satellite; tools.tool.run and tools.net.probe stay planned); tools.tool@1 1.1.0 adds optional keywords (what ?q= matches) and examples ({ title?, input, files? }), which contracts.tools.checkDescriptor checks against an embedded input schema, limits.maxInputBytes and files; a tool without an API has no examples; the descriptions say output.schema describes result.data for job tools too, $defs.input is false when api is false, and there is no planned status (planned and mirror ids are 404 tools.tool.not_found); Tools problem codes listed with their routes: 400 tools.query.invalid, 404 tools.tool.not_found, 413 tools.pdf.too_many_pages, 422 tools.pdf.wrong_password, 503 tools.unavailable, and with Retry-After 429 tools.quota.exceeded and 503 tools.busy; tools manifest 0.4.1; tools.tool and tools.tool-list fixtures are checked with checkDescriptor and checkList

OP OpenVibers committed 23 Sept 2026 · 61ebc7311b · 23 files, +682 −47


Changes

ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex 1fedb08e..9bb417ba 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -865,7 +865,7 @@
},
{
"id": "tools.tool",
-- "version": "1.0.0",
++ "version": "1.1.0",
"owner": "tools",
"schema": "tools/tool.v1.json",
"visibility": "public",
ddiff --git a/contracts/events/payloads/tools.job.failed.v1.json b/contracts/events/payloads/tools.job.failed.v1.json
iindex 18ed4d5b..11c5955b 100644
---- a/contracts/events/payloads/tools.job.failed.v1.json
++++ b/contracts/events/payloads/tools.job.failed.v1.json
@@@ -28,7 +28,7 @@
"description": "The job's problem (errors.problem@1) without request or trace ids.",
"properties": {
"status": { "type": "integer", "minimum": 400, "maximum": 599 },
-- "code": { "type": "string", "pattern": "^tools\\.[a-z0-9_.]+$", "description": "tools.job.failed, tools.job.timeout, or a code the tool chose (tools.…)." },
++ "code": { "type": "string", "pattern": "^tools\\.[a-z0-9_.]+$", "description": "tools.job.failed, tools.job.timeout, or a code the tool chose (tools.…: tools.pdf.too_many_pages, tools.pdf.wrong_password…)." },
"detail": { "type": "string", "maxLength": 500, "description": "Human-readable reason; server paths are scrubbed." }
}
},
ddiff --git a/contracts/tools/job-request.v1.json b/contracts/tools/job-request.v1.json
iindex 71b30c52..7777ff5b 100644
---- a/contracts/tools/job-request.v1.json
++++ b/contracts/tools/job-request.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/tools/job-request.v1.json",
"title": "ToolsJobRequest",
-- "description": "Body of POST /api/v1/jobs on the Tools satellites that run jobs (img, audio, docs; capability tools.job.create; OpenVibe.Tools apps/_shared/jobs/http.js). JSON, or multipart/form-data with the same fields as text parts (input as JSON text) and the input files as file or files parts. The Idempotency-Key header wins over idempotency_key: the same (owner, key) and request returns the same job (200 + Idempotent-Replayed: true), a different request under it is 409 tools.job.idempotency_conflict. Types: img.process (input { tool: convert|compress|resize|crop, … }, one image), audio.process ({ tool, … } as /api/process takes them, one audio or video file), docs.process ({ tool, … }, one PDF, or several files for merge and img2pdf). A format host fills in its format (webp.openvibe.tools converts to WebP). Answers tools.job@1 (202 + Location). Refusals are problem+json: 400 tools.job.invalid (input not a JSON object or over 16 KB, wrong file count, the type's own validation) or tools.job.unknown_type, 401 token.* or tools.job.no_owner, 403 capability.denied, 413 tools.job.too_large, 429 tools.job.too_many_active (unfinished jobs per owner) or a rate limit, 503 tools.job.unavailable.",
++ "description": "Body of POST /api/v1/jobs on the Tools satellites that run jobs (img, audio, docs; capability tools.job.create; OpenVibe.Tools apps/_shared/jobs/http.js). JSON, or multipart/form-data with the same fields as text parts (input as JSON text) and the input files as file or files parts. The Idempotency-Key header wins over idempotency_key: the same (owner, key) and request returns the same job (200 + Idempotent-Replayed: true), a different request under it is 409 tools.job.idempotency_conflict. Types: img.process (input { tool: convert|compress|resize|crop, … }, one image), audio.process ({ tool, … } as /api/process takes them, one audio or video file), docs.process ({ tool, … }, one PDF, or several files for merge and img2pdf). A format host fills in its format (webp.openvibe.tools converts to WebP). Answers tools.job@1 (202 + Location). Refusals are problem+json: 400 tools.job.invalid (input not a JSON object or over 16 KB, wrong file count, the type's own validation) or tools.job.unknown_type, 401 token.* or tools.job.no_owner, 403 capability.denied, 413 tools.job.too_large, 429 tools.job.too_many_active (unfinished jobs per owner), tools.quota.exceeded (Retry-After; the owner's allowance is spent) or a rate limit, 503 tools.job.unavailable, 503 tools.unavailable (a program or upstream the job type needs is missing), 503 tools.busy (Retry-After; every slot for this kind of work is taken). A tool that refuses its input after the job was accepted fails the job with its own code (413 tools.pdf.too_many_pages, 422 tools.pdf.wrong_password…; tools.job@1 error).",
"type": "object",
"required": ["type"],
"additionalProperties": false,
ddiff --git a/contracts/tools/job.v1.json b/contracts/tools/job.v1.json
iindex 5156a0a3..df67d244 100644
---- a/contracts/tools/job.v1.json
++++ b/contracts/tools/job.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/tools/job.v1.json",
"title": "ToolsJob",
-- "description": "A Tools job as the jobs API answers it (OpenVibe.Tools apps/_shared/jobs/http.js, system.view; ADR-027): POST /api/v1/jobs (202 + Location, or 200 + Idempotent-Replayed: true), GET and DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT and DELETE /api/v1/jobs/:id/references/:ref, the data of every SSE event on GET /api/v1/jobs/:id/events, and the job member of tools.run@1. A job is visible only to its owner (a person, a service/app principal, or one browser session); anyone else gets the same 404 tools.job.not_found as for a job that does not exist. result is null until the job succeeded; each result file is downloaded from its url (GET /api/v1/jobs/:id/files/:n, ?inline=1 for previews) and is stored as a private OpenVibe.Media object (storage media) or on the satellite until the job expires (storage local). expires_at is null while a reference keeps the result. error is problem+json: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), with server paths removed.",
++ "description": "A Tools job as the jobs API answers it (OpenVibe.Tools apps/_shared/jobs/http.js, system.view; ADR-027): POST /api/v1/jobs (202 + Location, or 200 + Idempotent-Replayed: true), GET and DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT and DELETE /api/v1/jobs/:id/references/:ref, the data of every SSE event on GET /api/v1/jobs/:id/events, and the job member of tools.run@1. A job is visible only to its owner (a person, a service/app principal, or one browser session); anyone else gets the same 404 tools.job.not_found as for a job that does not exist. result is null until the job succeeded; each result file is downloaded from its url (GET /api/v1/jobs/:id/files/:n, ?inline=1 for previews) and is stored as a private OpenVibe.Media object (storage media) or on the satellite until the job expires (storage local). expires_at is null while a reference keeps the result. error is problem+json: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), such as 413 tools.pdf.too_many_pages or 422 tools.pdf.wrong_password, with server paths removed.",
"type": "object",
"required": ["id", "object", "service", "type", "type_version", "state", "progress", "attempts", "max_attempts", "cancel_requested", "created_at", "started_at", "finished_at", "expires_at", "result", "error", "retryable", "retry_of", "retried_by", "references", "links"],
"additionalProperties": false,
ddiff --git a/contracts/tools/run-request.v1.json b/contracts/tools/run-request.v1.json
iindex 9d2e7b78..e8f0ddbe 100644
---- a/contracts/tools/run-request.v1.json
++++ b/contracts/tools/run-request.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/tools/run-request.v1.json",
"title": "ToolsRunRequest",
-- "description": "Body of POST /api/v1/tools/{id}/run on openvibe.tools (capability tools.tool.run, or tools.net.probe for network probes; ADR-027). JSON, or multipart/form-data like POST /api/v1/jobs: the text parts input (JSON text), wait_ms and idempotency_key, and uploaded files as file or files parts. The tool gets the uploaded files first, then the files references, in order; the count must fit the descriptor's files.min and files.max. input must match the tool's input schema (tools.tool@1 input). A job tool's job input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). The Idempotency-Key header wins over idempotency_key; ?wait_ms= on the query does the same as the field. Answers tools.run@1: 200 finished (succeeded, or failed with the tool's own error), 202 + Location while a job is queued or running, 200 + Idempotent-Replayed: true for a replay. Refusals are problem+json: 400 tools.run.invalid (not JSON, a bad field, wrong file count), 401 token.*, 403 capability.denied, 404 tools.tool.not_found, 404 tools.tool.not_runnable (api false: a page-only tool), 404 tools.run.file_not_found (a reference the caller cannot read), 409 tools.job.idempotency_conflict, 413 tools.file.too_large, 415 tools.file.unsupported_type (checked against the bytes), 422 tools.input.invalid (errors[] with JSON pointers), 429 quota.exceeded or tools.job.too_many_active (Retry-After), 503 tools.tool.unavailable.",
++ "description": "Body of POST /api/v1/tools/{id}/run on openvibe.tools (capability tools.tool.run, or tools.net.probe for network probes; ADR-027). JSON, or multipart/form-data like POST /api/v1/jobs: the text parts input (JSON text), wait_ms and idempotency_key, and uploaded files as file or files parts. The tool gets the uploaded files first, then the files references, in order; the count must fit the descriptor's files.min and files.max. input must match the tool's input schema (tools.tool@1 input). A job tool's job input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). The Idempotency-Key header wins over idempotency_key; ?wait_ms= on the query does the same as the field. Answers tools.run@1: 200 finished (succeeded, or failed with the tool's own error), 202 + Location while a job is queued or running, 200 + Idempotent-Replayed: true for a replay. Refusals are problem+json: 400 tools.run.invalid (not JSON, a bad field, wrong file count), 401 token.*, 403 capability.denied, 404 tools.tool.not_found, 404 tools.tool.not_runnable (api false: a page-only tool), 404 tools.run.file_not_found (a reference the caller cannot read), 409 tools.job.idempotency_conflict, 413 tools.file.too_large, 415 tools.file.unsupported_type (checked against the bytes), 422 tools.input.invalid (errors[] with JSON pointers), 429 tools.quota.exceeded (Retry-After; the caller's allowance in the tool's quota class is spent), quota.exceeded or tools.job.too_many_active (Retry-After), 503 tools.tool.unavailable (the descriptor's status is unavailable), 503 tools.unavailable (a program or upstream the tool needs is missing), 503 tools.busy (Retry-After; every slot for this kind of work is taken). A job tool's own refusals (413 tools.pdf.too_many_pages, 422 tools.pdf.wrong_password…) come back as a failed run (tools.run@1 error).",
"type": "object",
"additionalProperties": false,
"properties": {
ddiff --git a/contracts/tools/run.v1.json b/contracts/tools/run.v1.json
iindex 9e2a3599..cdde1a32 100644
---- a/contracts/tools/run.v1.json
++++ b/contracts/tools/run.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/tools/run.v1.json",
"title": "ToolsRun",
-- "description": "The answer of POST /api/v1/tools/{id}/run (tools.run-request@1; ADR-027). Finished: { state: succeeded, tool, result, took_ms } or { state: failed|cancelled, tool, error, took_ms }, with job set when the tool ran as a job. Not finished within wait_ms: { state: queued|running, tool, job, location }, sent with 202 and a Location header; follow the job there (GET, its events, its files; tools.job@1). result carries data (kind json, matching the descriptor's output schema), text (kind text) or files (kind file or files: the job's result files, downloaded from their url). took_ms runs from the request to the answer for an inline run, and from created_at to finished_at for a job. error is problem+json from the tool: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…). A request refused before the tool ran is a problem+json answer, not a run.",
++ "description": "The answer of POST /api/v1/tools/{id}/run (tools.run-request@1; ADR-027). Finished: { state: succeeded, tool, result, took_ms } or { state: failed|cancelled, tool, error, took_ms }, with job set when the tool ran as a job. Not finished within wait_ms: { state: queued|running, tool, job, location }, sent with 202 and a Location header; follow the job there (GET, its events, its files; tools.job@1). result carries data (kind json, matching the descriptor's output schema), text (kind text) or files (kind file or files: the job's result files, downloaded from their url). took_ms runs from the request to the answer for an inline run, and from created_at to finished_at for a job. error is problem+json from the tool: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), such as 413 tools.pdf.too_many_pages or 422 tools.pdf.wrong_password; its status is the problem's, not the HTTP answer's. A request refused before the tool ran is a problem+json answer, not a run.",
"oneOf": [
{
"type": "object", "required": ["state", "tool", "result", "took_ms"], "additionalProperties": false,
ddiff --git a/contracts/tools/tool-list.v1.json b/contracts/tools/tool-list.v1.json
iindex 43e701d1..095d8c1d 100644
---- a/contracts/tools/tool-list.v1.json
++++ b/contracts/tools/tool-list.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/tools/tool-list.v1.json",
"title": "ToolList",
-- "description": "The answer of GET /api/v1/tools on openvibe.tools (capability tools.tool.read; ADR-027): every tool's descriptor (tools.tool@1), with input and output schemas in the { $ref } form (GET /api/v1/tools/:id has them embedded). Filters: ?family=, ?execution=client|sync|job, ?api=true|false, ?status=, ?q= (name, summary and keywords). Planned placeholders are not tools and are not listed. Cacheable (ETag); updated_at changes when any descriptor does.",
++ "description": "The answer of GET /api/v1/tools on openvibe.tools (capability tools.tool.read; ADR-027): every tool's descriptor (tools.tool@1), with input and output schemas in the { $ref } form (GET /api/v1/tools/:id has them embedded). Filters: ?family=, ?execution=client|sync|job, ?api=true|false, ?status=, ?q= (every word must appear in the id, name, summary or keywords); family, execution and status take comma lists. An unknown execution or status value, or an api that is not true or false, is 400 tools.query.invalid. Every listed tool has one of the descriptor's statuses: there is no planned status, so planned placeholders are not tools and are not listed, and neither are mirrors (GET /api/v1/tools/:id answers 404 tools.tool.not_found for both, with a detail saying why). Cacheable (ETag); updated_at changes when any descriptor does.",
"type": "object",
"required": ["tools", "count", "updated_at"],
"additionalProperties": false,
ddiff --git a/contracts/tools/tool.v1.json b/contracts/tools/tool.v1.json
iindex 90ea932a..11e71072 100644
---- a/contracts/tools/tool.v1.json
++++ b/contracts/tools/tool.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/tools/tool.v1.json",
"title": "ToolDescriptor",
-- "description": "One tool on openvibe.tools as its registry describes it (GET /api/v1/tools/:id, and each item of GET /api/v1/tools; capability tools.tool.read; ADR-027). There is one descriptor per catalogue tool (GET /api/catalog.json tools[].id), built from the tool's own code, so its page, the run API, openvibe-sdk/tools, the OpenAPI document and the docs all read the same facts. EXECUTION is where the tool's engine runs for its page: client (in the browser; nothing leaves it), sync (a server request answered inline) or job (an asynchronous job on a satellite, tools.job@1). API says whether POST /api/v1/tools/{id}/run exposes the tool. A client tool has api true only when it also has a server engine (a pure transform that runs in Node), and its page stays browser-only. A page-only tool (yt) has api false and run null. A job tool's run creates a job of run.job.type whose input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). INPUT is the JSON Schema (2020-12) that a run request's input must match, embedded or as { $ref: https://openvibe.tools/api/v1/tools/{id}/schema#/$defs/input }. GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, and the list uses the $ref form. RULES this schema enforces: api false has no run, and api true has a run and an input; an API job tool names its job, and only job tools name one; an API tool whose output is a file runs as a job (its results are served as job files); a tools.net.probe tool fetches (egress) and is never anonymous; an anonymous egress tool has a per-target throttle (limits.perTargetPerMinute); a client tool never fetches; an unavailable tool says why (statusReason); JSON output has a schema. contracts.tools.checkDescriptor(d) also checks what depends on the id (run.path, $ref targets) and files.min <= files.max.",
++ "description": "One tool on openvibe.tools as its registry describes it (GET /api/v1/tools/:id, and each item of GET /api/v1/tools; capability tools.tool.read; ADR-027). There is one descriptor per catalogue tool (GET /api/catalog.json tools[].id), built from the tool's own code, so its page, the run API, openvibe-sdk/tools, the OpenAPI document and the docs all read the same facts. EXECUTION is where the tool's engine runs for its page: client (in the browser; nothing leaves it), sync (a server request answered inline) or job (an asynchronous job on a satellite, tools.job@1). API says whether POST /api/v1/tools/{id}/run exposes the tool. A client tool has api true only when it also has a server engine (a pure transform that runs in Node), and its page stays browser-only. A page-only tool (yt) has api false and run null. A job tool's run creates a job of run.job.type whose input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). INPUT is the JSON Schema (2020-12) that a run request's input must match, embedded or as { $ref: https://openvibe.tools/api/v1/tools/{id}/schema#/$defs/input }. GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, and the list uses the $ref form. When api is false, $defs.input is false (the schema that accepts nothing), since there is no run to take an input. OUTPUT.SCHEMA describes result.data for every execution: inline and job tools alike. KEYWORDS (the catalogue's search terms) and EXAMPLES (sample runs for the docs and the OpenAPI document) are optional. NOT A TOOL: there is no planned status. A planned catalogue entry has no descriptor and is not listed, and neither is a mirror (a second build of another tool). GET /api/v1/tools/:id and /schema answer 404 tools.tool.not_found for either, with a detail that says the id is planned or names the tool it mirrors, as for an id that does not exist. RULES this schema enforces: api false has no run and no examples, and api true has a run and an input; an API job tool names its job, and only job tools name one; an API tool whose output is a file runs as a job (its results are served as job files); a tools.net.probe tool fetches (egress) and is never anonymous; an anonymous egress tool has a per-target throttle (limits.perTargetPerMinute); a client tool never fetches; an unavailable tool says why (statusReason); JSON output has a schema. contracts.tools.checkDescriptor(d) also checks what depends on the id (run.path, $ref targets), files.min <= files.max, and each example: its input against an embedded input schema and limits.maxInputBytes, and its files against files (count and accept).",
"type": "object",
"required": ["id", "family", "name", "summary", "status", "execution", "api", "run", "input", "files", "output", "limits", "auth", "quotaClass", "cost", "egress", "hosts", "docs"],
"additionalProperties": false,
@@@ -11,9 +11,14 @@
"family": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$", "description": "The catalogue family: net, dev, img, audio, docs, text, media, places, pastes…" },
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"summary": { "type": "string", "minLength": 1, "maxLength": 200, "description": "One plain sentence: what the tool does." },
++ "keywords": {
++ "type": "array", "uniqueItems": true, "maxItems": 50,
++ "items": { "type": "string", "minLength": 1, "maxLength": 100, "pattern": "^\\S(.*\\S)?$" },
++ "description": "Optional. The catalogue's search terms for the tool (GET /api/catalog.json tools[].keywords): phrases people search for, such as 'heic to png' or 'yaml validator'. GET /api/v1/tools?q= matches every word of the query against the id, name, summary and these."
++ },
"status": {
"enum": ["stable", "beta", "preview", "unavailable"],
-- "description": "stable: works, and its input and output only grow. beta: works; input or output may still change in a minor. preview: works with known gaps (limits or output not final). unavailable: listed, but runs are refused with 503 tools.tool.unavailable and the page says so (statusReason)."
++ "description": "stable: works, and its input and output only grow. beta: works; input or output may still change in a minor. preview: works with known gaps (limits or output not final). unavailable: listed, but runs are refused with 503 tools.tool.unavailable and the page says so (statusReason); a program or upstream the engine needs is missing (qpdf, pdftoppm, heif-dec, ffmpeg…), and the engine's own routes answer 503 tools.unavailable while it is. There is no planned status: a planned catalogue entry is not a tool, so it has no descriptor and is not listed."
},
"statusReason": { "type": "string", "minLength": 1, "maxLength": 300, "description": "Why the status is what it is, for people (e.g. 'needs qpdf on the host'). Required when unavailable." },
"execution": { "enum": ["client", "sync", "job"], "description": "Where the engine runs for the tool's page: client (the browser), sync (a server request answered inline), job (an asynchronous job)." },
@@@ -41,7 +46,7 @@
}
},
"input": {
-- "description": "JSON Schema (2020-12) of the run request's input: embedded (an object schema), or { $ref } to $defs/input of GET /api/v1/tools/:id/schema. null only for a tool without an API.",
++ "description": "JSON Schema (2020-12) of the run request's input: embedded (an object schema), or { $ref } to $defs/input of GET /api/v1/tools/:id/schema. null only for a tool without an API, whose $defs/input is false (it accepts nothing).",
"anyOf": [
{ "type": "object", "required": ["$ref"], "maxProperties": 1, "properties": { "$ref": { "type": "string", "format": "uri" } } },
{ "type": "object", "required": ["type"], "properties": { "type": { "const": "object" } } },
@@@ -62,12 +67,35 @@
"maxBytes": { "type": "integer", "minimum": 1, "description": "Largest accepted file." }
}
},
++ "examples": {
++ "type": "array", "maxItems": 50,
++ "description": "Optional. Sample runs, which the docs and the generated OpenAPI document publish. Each example's input is valid against the tool's input schema and fits limits.maxInputBytes, and its files fit files (count and accept); contracts.tools.checkDescriptor checks this whenever input is embedded. Only a tool with an API has examples.",
++ "items": {
++ "type": "object", "required": ["input"], "additionalProperties": false,
++ "properties": {
++ "title": { "type": "string", "minLength": 1, "maxLength": 120, "description": "What the example shows, for people." },
++ "input": { "type": "object", "description": "A run request's input (tools.run-request@1 input), valid against the tool's input schema." },
++ "files": {
++ "type": "array", "maxItems": 100,
++ "description": "The files the sample run takes (uploaded parts or files references), in order: what each one is, and where to download a sample.",
++ "items": {
++ "type": "object", "required": ["mime"], "additionalProperties": false,
++ "properties": {
++ "name": { "type": "string", "minLength": 1, "maxLength": 200, "description": "A file name for the sample (photo.jpg)." },
++ "mime": { "type": "string", "pattern": "^[a-z]+/[a-z0-9][a-z0-9.+-]*$", "description": "Its media type, one that files.accept allows." },
++ "url": { "type": "string", "format": "uri", "pattern": "^https://", "description": "A sample file to download." }
++ }
++ }
++ }
++ }
++ }
++ },
"output": {
"type": "object", "required": ["kind"], "additionalProperties": false,
"properties": {
"kind": { "enum": ["json", "file", "files", "text"], "description": "json: result.data; text: result.text; file or files: result.files (job result files)." },
"schema": {
-- "description": "JSON Schema of result.data (kind json): embedded, or { $ref } to $defs/output of GET /api/v1/tools/:id/schema.",
++ "description": "JSON Schema of result.data, whatever the execution: for a job tool too, where it describes the data of the run's result and of its job's result (tools.job@1 result.data), which are the same object. Required when kind is json. Embedded, or { $ref } to $defs/output of GET /api/v1/tools/:id/schema.",
"anyOf": [
{ "type": "object", "required": ["$ref"], "maxProperties": 1, "properties": { "$ref": { "type": "string", "format": "uri" } } },
{ "type": "object", "required": ["type"], "properties": { "type": { "const": "object" } } }
@@@ -88,7 +116,7 @@
"timeoutMs": { "type": "integer", "minimum": 1, "maximum": 3600000, "description": "A run (or its job) longer than this fails with tools.job.timeout or 504." },
"maxDurationSec": { "type": "integer", "minimum": 1, "description": "Longest audio or video input." },
"maxPixels": { "type": "integer", "minimum": 1, "description": "Largest image input (width × height)." },
-- "maxPages": { "type": "integer", "minimum": 1, "description": "Most PDF pages." },
++ "maxPages": { "type": "integer", "minimum": 1, "description": "Most PDF pages. A longer document fails with 413 tools.pdf.too_many_pages." },
"maxInputBytes": { "type": "integer", "minimum": 1, "description": "Largest input as JSON (text to transform, lists of hosts…)." },
"perTargetPerMinute": { "type": "integer", "minimum": 1, "description": "Egress tools: most runs per minute against one target host or address, across all callers." }
}
@@@ -106,7 +134,7 @@
},
"quotaClass": {
"type": "string", "pattern": "^tools-[a-z0-9]+(-[a-z0-9]+)*$",
-- "description": "The quota bucket a run counts against, with allowances per caller tier. Names describe the work, never a tier or a price (tools-run, tools-job, tools-probe, tools-fetch…), so a paid tier later adds allowances, not names."
++ "description": "The quota bucket a run counts against, with allowances per caller tier. Names describe the work, never a tier or a price (tools-run, tools-job, tools-probe, tools-fetch…), so a paid tier later adds allowances, not names. A caller whose allowance is spent gets 429 tools.quota.exceeded with Retry-After."
},
"cost": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Relative weight of one run within its quota class (1 = a cheap lookup or transform). Quotas count weight, not requests." },
"egress": { "type": "boolean", "description": "The server fetches a host or URL the caller chose (DNS, WHOIS, headers, Open Graph, ports…), always through the SSRF guard." },
@@@ -123,7 +151,7 @@
"allOf": [
{
"if": { "properties": { "api": { "const": false } }, "required": ["api"] },
-- "then": { "properties": { "run": { "type": "null" } } }
++ "then": { "properties": { "run": { "type": "null" }, "examples": false } }
},
{
"if": { "properties": { "api": { "const": true } }, "required": ["api"] },
ddiff --git a/fixtures/tools.tool-list/valid/four-tools.json b/fixtures/tools.tool-list/valid/four-tools.json
iindex 0b8ab325..124e3899 100644
---- a/fixtures/tools.tool-list/valid/four-tools.json
++++ b/fixtures/tools.tool-list/valid/four-tools.json
@@@ -5,6 +5,12 @@
"family": "img",
"name": "PNG Converter",
"summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "keywords": [
++ "jpg to png",
++ "webp to png",
++ "heic to png",
++ "convert image to png"
++ ],
"status": "stable",
"execution": "job",
"api": true,
@@@ -61,6 +67,12 @@
"family": "net",
"name": "DNS Lookup",
"summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "keywords": [
++ "dns lookup",
++ "mx record lookup",
++ "txt record check",
++ "nslookup online"
++ ],
"status": "stable",
"execution": "sync",
"api": true,
@@@ -104,6 +116,10 @@
"family": "net",
"name": "Port Checker",
"summary": "Check whether TCP ports on a host are open.",
++ "keywords": [
++ "open port checker",
++ "port scanner"
++ ],
"status": "beta",
"execution": "sync",
"api": true,
ddiff --git a/fixtures/tools.tool/invalid/example-file-not-accepted.json b/fixtures/tools.tool/invalid/example-file-not-accepted.json
nnew file mode 100644
iindex 00000000..8119866f
---- /dev/null
++++ b/fixtures/tools.tool/invalid/example-file-not-accepted.json
@@@ -0,0 +1,87 @@
++{
++ "id": "png",
++ "family": "img",
++ "name": "PNG Converter",
++ "summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "keywords": [
++ "jpg to png",
++ "webp to png",
++ "heic to png",
++ "convert image to png"
++ ],
++ "status": "stable",
++ "execution": "job",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/png/run",
++ "job": {
++ "type": "img.process",
++ "operation": "convert",
++ "preset": {
++ "format": "png"
++ }
++ },
++ "legacy": [
++ "POST /api/process",
++ "POST /api/process/direct"
++ ]
++ },
++ "input": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "compressionLevel": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 9
++ },
++ "width": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 16384
++ }
++ }
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "image/*"
++ ],
++ "maxBytes": 52428800
++ },
++ "examples": [
++ {
++ "title": "A PDF is not an image",
++ "input": {},
++ "files": [
++ {
++ "name": "scan.pdf",
++ "mime": "application/pdf"
++ }
++ ]
++ }
++ ],
++ "output": {
++ "kind": "file",
++ "mime": [
++ "image/png"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 120000,
++ "maxPixels": 100000000
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 5,
++ "egress": false,
++ "hosts": [
++ "png.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/png"
++}
ddiff --git a/fixtures/tools.tool/invalid/example-input-violates-schema.json b/fixtures/tools.tool/invalid/example-input-violates-schema.json
nnew file mode 100644
iindex 00000000..fdf37af4
---- /dev/null
++++ b/fixtures/tools.tool/invalid/example-input-violates-schema.json
@@@ -0,0 +1,89 @@
++{
++ "id": "png",
++ "family": "img",
++ "name": "PNG Converter",
++ "summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "keywords": [
++ "jpg to png",
++ "webp to png",
++ "heic to png",
++ "convert image to png"
++ ],
++ "status": "stable",
++ "execution": "job",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/png/run",
++ "job": {
++ "type": "img.process",
++ "operation": "convert",
++ "preset": {
++ "format": "png"
++ }
++ },
++ "legacy": [
++ "POST /api/process",
++ "POST /api/process/direct"
++ ]
++ },
++ "input": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "compressionLevel": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 9
++ },
++ "width": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 16384
++ }
++ }
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "image/*"
++ ],
++ "maxBytes": 52428800
++ },
++ "examples": [
++ {
++ "title": "Compression level 12 (the schema allows 0 to 9)",
++ "input": {
++ "compressionLevel": 12
++ },
++ "files": [
++ {
++ "name": "photo.jpg",
++ "mime": "image/jpeg"
++ }
++ ]
++ }
++ ],
++ "output": {
++ "kind": "file",
++ "mime": [
++ "image/png"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 120000,
++ "maxPixels": 100000000
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 5,
++ "egress": false,
++ "hosts": [
++ "png.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/png"
++}
ddiff --git a/fixtures/tools.tool/invalid/examples-on-page-only-tool.json b/fixtures/tools.tool/invalid/examples-on-page-only-tool.json
nnew file mode 100644
iindex 00000000..c2a38bbb
---- /dev/null
++++ b/fixtures/tools.tool/invalid/examples-on-page-only-tool.json
@@@ -0,0 +1,43 @@
++{
++ "id": "yt",
++ "family": "media",
++ "name": "YouTube Downloader",
++ "summary": "Save a YouTube video or its audio.",
++ "status": "stable",
++ "execution": "job",
++ "api": false,
++ "run": null,
++ "input": null,
++ "files": null,
++ "examples": [
++ {
++ "input": {
++ "url": "https://www.youtube.com/watch?v=aqz-KE-bpKQ"
++ }
++ }
++ ],
++ "output": {
++ "kind": "file",
++ "mime": [
++ "video/mp4",
++ "audio/mpeg"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 900000,
++ "maxDurationSec": 3600,
++ "perTargetPerMinute": 20
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-download",
++ "cost": 50,
++ "egress": true,
++ "hosts": [
++ "youtube-downloader.openvibe.tools",
++ "yt.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/yt"
++}
ddiff --git a/fixtures/tools.tool/invalid/keyword-blank.json b/fixtures/tools.tool/invalid/keyword-blank.json
nnew file mode 100644
iindex 00000000..2f940a34
---- /dev/null
++++ b/fixtures/tools.tool/invalid/keyword-blank.json
@@@ -0,0 +1,98 @@
++{
++ "id": "png",
++ "family": "img",
++ "name": "PNG Converter",
++ "summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "keywords": [
++ "jpg to png",
++ " "
++ ],
++ "status": "stable",
++ "execution": "job",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/png/run",
++ "job": {
++ "type": "img.process",
++ "operation": "convert",
++ "preset": {
++ "format": "png"
++ }
++ },
++ "legacy": [
++ "POST /api/process",
++ "POST /api/process/direct"
++ ]
++ },
++ "input": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "compressionLevel": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 9
++ },
++ "width": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 16384
++ }
++ }
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "image/*"
++ ],
++ "maxBytes": 52428800
++ },
++ "examples": [
++ {
++ "title": "Convert a JPEG photo to PNG",
++ "input": {},
++ "files": [
++ {
++ "name": "photo.jpg",
++ "mime": "image/jpeg"
++ }
++ ]
++ },
++ {
++ "title": "Convert and shrink to 512 px wide, smallest file",
++ "input": {
++ "width": 512,
++ "compressionLevel": 9
++ },
++ "files": [
++ {
++ "name": "screenshot.webp",
++ "mime": "image/webp"
++ }
++ ]
++ }
++ ],
++ "output": {
++ "kind": "file",
++ "mime": [
++ "image/png"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 120000,
++ "maxPixels": 100000000
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 5,
++ "egress": false,
++ "hosts": [
++ "png.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/png"
++}
ddiff --git a/fixtures/tools.tool/valid/dns-sync-egress.json b/fixtures/tools.tool/valid/dns-sync-egress.json
iindex 28bbb228..ed02227a 100644
---- a/fixtures/tools.tool/valid/dns-sync-egress.json
++++ b/fixtures/tools.tool/valid/dns-sync-egress.json
@@@ -3,6 +3,12 @@
"family": "net",
"name": "DNS Lookup",
"summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "keywords": [
++ "dns lookup",
++ "mx record lookup",
++ "txt record check",
++ "nslookup online"
++ ],
"status": "stable",
"execution": "sync",
"api": true,
@@@ -18,6 +24,15 @@
"$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/input"
},
"files": null,
++ "examples": [
++ {
++ "title": "MX records of a domain",
++ "input": {
++ "target": "example.com",
++ "type": "MX"
++ }
++ }
++ ],
"output": {
"kind": "json",
"schema": {
ddiff --git a/fixtures/tools.tool/valid/jsonminify-client-engine.json b/fixtures/tools.tool/valid/jsonminify-client-engine.json
iindex 33ce0274..2102756e 100644
---- a/fixtures/tools.tool/valid/jsonminify-client-engine.json
++++ b/fixtures/tools.tool/valid/jsonminify-client-engine.json
@@@ -3,6 +3,11 @@
"family": "dev",
"name": "JSON Minifier",
"summary": "Strip the whitespace out of JSON.",
++ "keywords": [
++ "json minifier",
++ "minify json",
++ "compact json"
++ ],
"status": "stable",
"execution": "client",
"api": true,
@@@ -25,6 +30,14 @@
}
},
"files": null,
++ "examples": [
++ {
++ "title": "Minify a small object",
++ "input": {
++ "text": "{\n \"name\": \"OpenVibe\",\n \"tools\": [1, 2, 3]\n}"
++ }
++ }
++ ],
"output": {
"kind": "text"
},
ddiff --git a/fixtures/tools.tool/valid/png-job.json b/fixtures/tools.tool/valid/png-job.json
iindex f45b1517..3a2711fd 100644
---- a/fixtures/tools.tool/valid/png-job.json
++++ b/fixtures/tools.tool/valid/png-job.json
@@@ -3,6 +3,12 @@
"family": "img",
"name": "PNG Converter",
"summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "keywords": [
++ "jpg to png",
++ "webp to png",
++ "heic to png",
++ "convert image to png"
++ ],
"status": "stable",
"execution": "job",
"api": true,
@@@ -45,6 +51,31 @@
],
"maxBytes": 52428800
},
++ "examples": [
++ {
++ "title": "Convert a JPEG photo to PNG",
++ "input": {},
++ "files": [
++ {
++ "name": "photo.jpg",
++ "mime": "image/jpeg"
++ }
++ ]
++ },
++ {
++ "title": "512 px wide, at the highest compression level",
++ "input": {
++ "width": 512,
++ "compressionLevel": 9
++ },
++ "files": [
++ {
++ "name": "screenshot.webp",
++ "mime": "image/webp"
++ }
++ ]
++ }
++ ],
"output": {
"kind": "file",
"mime": [
ddiff --git a/fixtures/tools.tool/valid/port-probe.json b/fixtures/tools.tool/valid/port-probe.json
iindex ac472a8c..4b18e595 100644
---- a/fixtures/tools.tool/valid/port-probe.json
++++ b/fixtures/tools.tool/valid/port-probe.json
@@@ -3,6 +3,10 @@
"family": "net",
"name": "Port Checker",
"summary": "Check whether TCP ports on a host are open.",
++ "keywords": [
++ "open port checker",
++ "port scanner"
++ ],
"status": "beta",
"execution": "sync",
"api": true,
@@@ -39,6 +43,17 @@
}
},
"files": null,
++ "examples": [
++ {
++ "title": "Is HTTPS open?",
++ "input": {
++ "host": "example.com",
++ "ports": [
++ 443
++ ]
++ }
++ }
++ ],
"output": {
"kind": "json",
"schema": {
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex e2cd5800..cd44bfaa 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -8245,7 +8245,7 @@
"code": {
"type": "string",
"pattern": "^tools\\.[a-z0-9_.]+$",
-- "description": "tools.job.failed, tools.job.timeout, or a code the tool chose (tools.…)."
++ "description": "tools.job.failed, tools.job.timeout, or a code the tool chose (tools.…: tools.pdf.too_many_pages, tools.pdf.wrong_password…)."
},
"detail": {
"type": "string",
@@@ -9636,14 +9636,14 @@
}
},
"tools.tool": {
-- "version": "1.0.0",
++ "version": "1.1.0",
"owner": "tools",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/tools/tool.v1.json",
"title": "ToolDescriptor",
-- "description": "One tool on openvibe.tools as its registry describes it (GET /api/v1/tools/:id, and each item of GET /api/v1/tools; capability tools.tool.read; ADR-027). There is one descriptor per catalogue tool (GET /api/catalog.json tools[].id), built from the tool's own code, so its page, the run API, openvibe-sdk/tools, the OpenAPI document and the docs all read the same facts. EXECUTION is where the tool's engine runs for its page: client (in the browser; nothing leaves it), sync (a server request answered inline) or job (an asynchronous job on a satellite, tools.job@1). API says whether POST /api/v1/tools/{id}/run exposes the tool. A client tool has api true only when it also has a server engine (a pure transform that runs in Node), and its page stays browser-only. A page-only tool (yt) has api false and run null. A job tool's run creates a job of run.job.type whose input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). INPUT is the JSON Schema (2020-12) that a run request's input must match, embedded or as { $ref: https://openvibe.tools/api/v1/tools/{id}/schema#/$defs/input }. GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, and the list uses the $ref form. RULES this schema enforces: api false has no run, and api true has a run and an input; an API job tool names its job, and only job tools name one; an API tool whose output is a file runs as a job (its results are served as job files); a tools.net.probe tool fetches (egress) and is never anonymous; an anonymous egress tool has a per-target throttle (limits.perTargetPerMinute); a client tool never fetches; an unavailable tool says why (statusReason); JSON output has a schema. contracts.tools.checkDescriptor(d) also checks what depends on the id (run.path, $ref targets) and files.min <= files.max.",
++ "description": "One tool on openvibe.tools as its registry describes it (GET /api/v1/tools/:id, and each item of GET /api/v1/tools; capability tools.tool.read; ADR-027). There is one descriptor per catalogue tool (GET /api/catalog.json tools[].id), built from the tool's own code, so its page, the run API, openvibe-sdk/tools, the OpenAPI document and the docs all read the same facts. EXECUTION is where the tool's engine runs for its page: client (in the browser; nothing leaves it), sync (a server request answered inline) or job (an asynchronous job on a satellite, tools.job@1). API says whether POST /api/v1/tools/{id}/run exposes the tool. A client tool has api true only when it also has a server engine (a pure transform that runs in Node), and its page stays browser-only. A page-only tool (yt) has api false and run null. A job tool's run creates a job of run.job.type whose input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). INPUT is the JSON Schema (2020-12) that a run request's input must match, embedded or as { $ref: https://openvibe.tools/api/v1/tools/{id}/schema#/$defs/input }. GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, and the list uses the $ref form. When api is false, $defs.input is false (the schema that accepts nothing), since there is no run to take an input. OUTPUT.SCHEMA describes result.data for every execution: inline and job tools alike. KEYWORDS (the catalogue's search terms) and EXAMPLES (sample runs for the docs and the OpenAPI document) are optional. NOT A TOOL: there is no planned status. A planned catalogue entry has no descriptor and is not listed, and neither is a mirror (a second build of another tool). GET /api/v1/tools/:id and /schema answer 404 tools.tool.not_found for either, with a detail that says the id is planned or names the tool it mirrors, as for an id that does not exist. RULES this schema enforces: api false has no run and no examples, and api true has a run and an input; an API job tool names its job, and only job tools name one; an API tool whose output is a file runs as a job (its results are served as job files); a tools.net.probe tool fetches (egress) and is never anonymous; an anonymous egress tool has a per-target throttle (limits.perTargetPerMinute); a client tool never fetches; an unavailable tool says why (statusReason); JSON output has a schema. contracts.tools.checkDescriptor(d) also checks what depends on the id (run.path, $ref targets), files.min <= files.max, and each example: its input against an embedded input schema and limits.maxInputBytes, and its files against files (count and accept).",
"type": "object",
"required": [
"id",
@@@ -9688,6 +9688,18 @@
"maxLength": 200,
"description": "One plain sentence: what the tool does."
},
++ "keywords": {
++ "type": "array",
++ "uniqueItems": true,
++ "maxItems": 50,
++ "items": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 100,
++ "pattern": "^\\S(.*\\S)?$"
++ },
++ "description": "Optional. The catalogue's search terms for the tool (GET /api/catalog.json tools[].keywords): phrases people search for, such as 'heic to png' or 'yaml validator'. GET /api/v1/tools?q= matches every word of the query against the id, name, summary and these."
++ },
"status": {
"enum": [
"stable",
@@@ -9695,7 +9707,7 @@
"preview",
"unavailable"
],
-- "description": "stable: works, and its input and output only grow. beta: works; input or output may still change in a minor. preview: works with known gaps (limits or output not final). unavailable: listed, but runs are refused with 503 tools.tool.unavailable and the page says so (statusReason)."
++ "description": "stable: works, and its input and output only grow. beta: works; input or output may still change in a minor. preview: works with known gaps (limits or output not final). unavailable: listed, but runs are refused with 503 tools.tool.unavailable and the page says so (statusReason); a program or upstream the engine needs is missing (qpdf, pdftoppm, heif-dec, ffmpeg…), and the engine's own routes answer 503 tools.unavailable while it is. There is no planned status: a planned catalogue entry is not a tool, so it has no descriptor and is not listed."
},
"statusReason": {
"type": "string",
@@@ -9778,7 +9790,7 @@
}
},
"input": {
-- "description": "JSON Schema (2020-12) of the run request's input: embedded (an object schema), or { $ref } to $defs/input of GET /api/v1/tools/:id/schema. null only for a tool without an API.",
++ "description": "JSON Schema (2020-12) of the run request's input: embedded (an object schema), or { $ref } to $defs/input of GET /api/v1/tools/:id/schema. null only for a tool without an API, whose $defs/input is false (it accepts nothing).",
"anyOf": [
{
"type": "object",
@@@ -9850,6 +9862,61 @@
}
}
},
++ "examples": {
++ "type": "array",
++ "maxItems": 50,
++ "description": "Optional. Sample runs, which the docs and the generated OpenAPI document publish. Each example's input is valid against the tool's input schema and fits limits.maxInputBytes, and its files fit files (count and accept); contracts.tools.checkDescriptor checks this whenever input is embedded. Only a tool with an API has examples.",
++ "items": {
++ "type": "object",
++ "required": [
++ "input"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "title": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 120,
++ "description": "What the example shows, for people."
++ },
++ "input": {
++ "type": "object",
++ "description": "A run request's input (tools.run-request@1 input), valid against the tool's input schema."
++ },
++ "files": {
++ "type": "array",
++ "maxItems": 100,
++ "description": "The files the sample run takes (uploaded parts or files references), in order: what each one is, and where to download a sample.",
++ "items": {
++ "type": "object",
++ "required": [
++ "mime"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "A file name for the sample (photo.jpg)."
++ },
++ "mime": {
++ "type": "string",
++ "pattern": "^[a-z]+/[a-z0-9][a-z0-9.+-]*$",
++ "description": "Its media type, one that files.accept allows."
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https://",
++ "description": "A sample file to download."
++ }
++ }
++ }
++ }
++ }
++ }
++ },
"output": {
"type": "object",
"required": [
@@@ -9867,7 +9934,7 @@
"description": "json: result.data; text: result.text; file or files: result.files (job result files)."
},
"schema": {
-- "description": "JSON Schema of result.data (kind json): embedded, or { $ref } to $defs/output of GET /api/v1/tools/:id/schema.",
++ "description": "JSON Schema of result.data, whatever the execution: for a job tool too, where it describes the data of the run's result and of its job's result (tools.job@1 result.data), which are the same object. Required when kind is json. Embedded, or { $ref } to $defs/output of GET /api/v1/tools/:id/schema.",
"anyOf": [
{
"type": "object",
@@@ -9948,7 +10015,7 @@
"maxPages": {
"type": "integer",
"minimum": 1,
-- "description": "Most PDF pages."
++ "description": "Most PDF pages. A longer document fails with 413 tools.pdf.too_many_pages."
},
"maxInputBytes": {
"type": "integer",
@@@ -9987,7 +10054,7 @@
"quotaClass": {
"type": "string",
"pattern": "^tools-[a-z0-9]+(-[a-z0-9]+)*$",
-- "description": "The quota bucket a run counts against, with allowances per caller tier. Names describe the work, never a tier or a price (tools-run, tools-job, tools-probe, tools-fetch…), so a paid tier later adds allowances, not names."
++ "description": "The quota bucket a run counts against, with allowances per caller tier. Names describe the work, never a tier or a price (tools-run, tools-job, tools-probe, tools-fetch…), so a paid tier later adds allowances, not names. A caller whose allowance is spent gets 429 tools.quota.exceeded with Retry-After."
},
"cost": {
"type": "integer",
@@@ -10039,7 +10106,8 @@
"properties": {
"run": {
"type": "null"
-- }
++ },
++ "examples": false
}
}
},
@@@ -10274,7 +10342,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/tools/tool-list.v1.json",
"title": "ToolList",
-- "description": "The answer of GET /api/v1/tools on openvibe.tools (capability tools.tool.read; ADR-027): every tool's descriptor (tools.tool@1), with input and output schemas in the { $ref } form (GET /api/v1/tools/:id has them embedded). Filters: ?family=, ?execution=client|sync|job, ?api=true|false, ?status=, ?q= (name, summary and keywords). Planned placeholders are not tools and are not listed. Cacheable (ETag); updated_at changes when any descriptor does.",
++ "description": "The answer of GET /api/v1/tools on openvibe.tools (capability tools.tool.read; ADR-027): every tool's descriptor (tools.tool@1), with input and output schemas in the { $ref } form (GET /api/v1/tools/:id has them embedded). Filters: ?family=, ?execution=client|sync|job, ?api=true|false, ?status=, ?q= (every word must appear in the id, name, summary or keywords); family, execution and status take comma lists. An unknown execution or status value, or an api that is not true or false, is 400 tools.query.invalid. Every listed tool has one of the descriptor's statuses: there is no planned status, so planned placeholders are not tools and are not listed, and neither are mirrors (GET /api/v1/tools/:id answers 404 tools.tool.not_found for both, with a detail saying why). Cacheable (ETag); updated_at changes when any descriptor does.",
"type": "object",
"required": [
"tools",
@@@ -10337,7 +10405,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/tools/run-request.v1.json",
"title": "ToolsRunRequest",
-- "description": "Body of POST /api/v1/tools/{id}/run on openvibe.tools (capability tools.tool.run, or tools.net.probe for network probes; ADR-027). JSON, or multipart/form-data like POST /api/v1/jobs: the text parts input (JSON text), wait_ms and idempotency_key, and uploaded files as file or files parts. The tool gets the uploaded files first, then the files references, in order; the count must fit the descriptor's files.min and files.max. input must match the tool's input schema (tools.tool@1 input). A job tool's job input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). The Idempotency-Key header wins over idempotency_key; ?wait_ms= on the query does the same as the field. Answers tools.run@1: 200 finished (succeeded, or failed with the tool's own error), 202 + Location while a job is queued or running, 200 + Idempotent-Replayed: true for a replay. Refusals are problem+json: 400 tools.run.invalid (not JSON, a bad field, wrong file count), 401 token.*, 403 capability.denied, 404 tools.tool.not_found, 404 tools.tool.not_runnable (api false: a page-only tool), 404 tools.run.file_not_found (a reference the caller cannot read), 409 tools.job.idempotency_conflict, 413 tools.file.too_large, 415 tools.file.unsupported_type (checked against the bytes), 422 tools.input.invalid (errors[] with JSON pointers), 429 quota.exceeded or tools.job.too_many_active (Retry-After), 503 tools.tool.unavailable.",
++ "description": "Body of POST /api/v1/tools/{id}/run on openvibe.tools (capability tools.tool.run, or tools.net.probe for network probes; ADR-027). JSON, or multipart/form-data like POST /api/v1/jobs: the text parts input (JSON text), wait_ms and idempotency_key, and uploaded files as file or files parts. The tool gets the uploaded files first, then the files references, in order; the count must fit the descriptor's files.min and files.max. input must match the tool's input schema (tools.tool@1 input). A job tool's job input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). The Idempotency-Key header wins over idempotency_key; ?wait_ms= on the query does the same as the field. Answers tools.run@1: 200 finished (succeeded, or failed with the tool's own error), 202 + Location while a job is queued or running, 200 + Idempotent-Replayed: true for a replay. Refusals are problem+json: 400 tools.run.invalid (not JSON, a bad field, wrong file count), 401 token.*, 403 capability.denied, 404 tools.tool.not_found, 404 tools.tool.not_runnable (api false: a page-only tool), 404 tools.run.file_not_found (a reference the caller cannot read), 409 tools.job.idempotency_conflict, 413 tools.file.too_large, 415 tools.file.unsupported_type (checked against the bytes), 422 tools.input.invalid (errors[] with JSON pointers), 429 tools.quota.exceeded (Retry-After; the caller's allowance in the tool's quota class is spent), quota.exceeded or tools.job.too_many_active (Retry-After), 503 tools.tool.unavailable (the descriptor's status is unavailable), 503 tools.unavailable (a program or upstream the tool needs is missing), 503 tools.busy (Retry-After; every slot for this kind of work is taken). A job tool's own refusals (413 tools.pdf.too_many_pages, 422 tools.pdf.wrong_password…) come back as a failed run (tools.run@1 error).",
"type": "object",
"additionalProperties": false,
"properties": {
@@@ -10410,7 +10478,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/tools/run.v1.json",
"title": "ToolsRun",
-- "description": "The answer of POST /api/v1/tools/{id}/run (tools.run-request@1; ADR-027). Finished: { state: succeeded, tool, result, took_ms } or { state: failed|cancelled, tool, error, took_ms }, with job set when the tool ran as a job. Not finished within wait_ms: { state: queued|running, tool, job, location }, sent with 202 and a Location header; follow the job there (GET, its events, its files; tools.job@1). result carries data (kind json, matching the descriptor's output schema), text (kind text) or files (kind file or files: the job's result files, downloaded from their url). took_ms runs from the request to the answer for an inline run, and from created_at to finished_at for a job. error is problem+json from the tool: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…). A request refused before the tool ran is a problem+json answer, not a run.",
++ "description": "The answer of POST /api/v1/tools/{id}/run (tools.run-request@1; ADR-027). Finished: { state: succeeded, tool, result, took_ms } or { state: failed|cancelled, tool, error, took_ms }, with job set when the tool ran as a job. Not finished within wait_ms: { state: queued|running, tool, job, location }, sent with 202 and a Location header; follow the job there (GET, its events, its files; tools.job@1). result carries data (kind json, matching the descriptor's output schema), text (kind text) or files (kind file or files: the job's result files, downloaded from their url). took_ms runs from the request to the answer for an inline run, and from created_at to finished_at for a job. error is problem+json from the tool: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), such as 413 tools.pdf.too_many_pages or 422 tools.pdf.wrong_password; its status is the problem's, not the HTTP answer's. A request refused before the tool ran is a problem+json answer, not a run.",
"oneOf": [
{
"type": "object",
@@@ -10637,7 +10705,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/tools/job.v1.json",
"title": "ToolsJob",
-- "description": "A Tools job as the jobs API answers it (OpenVibe.Tools apps/_shared/jobs/http.js, system.view; ADR-027): POST /api/v1/jobs (202 + Location, or 200 + Idempotent-Replayed: true), GET and DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT and DELETE /api/v1/jobs/:id/references/:ref, the data of every SSE event on GET /api/v1/jobs/:id/events, and the job member of tools.run@1. A job is visible only to its owner (a person, a service/app principal, or one browser session); anyone else gets the same 404 tools.job.not_found as for a job that does not exist. result is null until the job succeeded; each result file is downloaded from its url (GET /api/v1/jobs/:id/files/:n, ?inline=1 for previews) and is stored as a private OpenVibe.Media object (storage media) or on the satellite until the job expires (storage local). expires_at is null while a reference keeps the result. error is problem+json: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), with server paths removed.",
++ "description": "A Tools job as the jobs API answers it (OpenVibe.Tools apps/_shared/jobs/http.js, system.view; ADR-027): POST /api/v1/jobs (202 + Location, or 200 + Idempotent-Replayed: true), GET and DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT and DELETE /api/v1/jobs/:id/references/:ref, the data of every SSE event on GET /api/v1/jobs/:id/events, and the job member of tools.run@1. A job is visible only to its owner (a person, a service/app principal, or one browser session); anyone else gets the same 404 tools.job.not_found as for a job that does not exist. result is null until the job succeeded; each result file is downloaded from its url (GET /api/v1/jobs/:id/files/:n, ?inline=1 for previews) and is stored as a private OpenVibe.Media object (storage media) or on the satellite until the job expires (storage local). expires_at is null while a reference keeps the result. error is problem+json: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), such as 413 tools.pdf.too_many_pages or 422 tools.pdf.wrong_password, with server paths removed.",
"type": "object",
"required": [
"id",
@@@ -10986,7 +11054,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/tools/job-request.v1.json",
"title": "ToolsJobRequest",
-- "description": "Body of POST /api/v1/jobs on the Tools satellites that run jobs (img, audio, docs; capability tools.job.create; OpenVibe.Tools apps/_shared/jobs/http.js). JSON, or multipart/form-data with the same fields as text parts (input as JSON text) and the input files as file or files parts. The Idempotency-Key header wins over idempotency_key: the same (owner, key) and request returns the same job (200 + Idempotent-Replayed: true), a different request under it is 409 tools.job.idempotency_conflict. Types: img.process (input { tool: convert|compress|resize|crop, … }, one image), audio.process ({ tool, … } as /api/process takes them, one audio or video file), docs.process ({ tool, … }, one PDF, or several files for merge and img2pdf). A format host fills in its format (webp.openvibe.tools converts to WebP). Answers tools.job@1 (202 + Location). Refusals are problem+json: 400 tools.job.invalid (input not a JSON object or over 16 KB, wrong file count, the type's own validation) or tools.job.unknown_type, 401 token.* or tools.job.no_owner, 403 capability.denied, 413 tools.job.too_large, 429 tools.job.too_many_active (unfinished jobs per owner) or a rate limit, 503 tools.job.unavailable.",
++ "description": "Body of POST /api/v1/jobs on the Tools satellites that run jobs (img, audio, docs; capability tools.job.create; OpenVibe.Tools apps/_shared/jobs/http.js). JSON, or multipart/form-data with the same fields as text parts (input as JSON text) and the input files as file or files parts. The Idempotency-Key header wins over idempotency_key: the same (owner, key) and request returns the same job (200 + Idempotent-Replayed: true), a different request under it is 409 tools.job.idempotency_conflict. Types: img.process (input { tool: convert|compress|resize|crop, … }, one image), audio.process ({ tool, … } as /api/process takes them, one audio or video file), docs.process ({ tool, … }, one PDF, or several files for merge and img2pdf). A format host fills in its format (webp.openvibe.tools converts to WebP). Answers tools.job@1 (202 + Location). Refusals are problem+json: 400 tools.job.invalid (input not a JSON object or over 16 KB, wrong file count, the type's own validation) or tools.job.unknown_type, 401 token.* or tools.job.no_owner, 403 capability.denied, 413 tools.job.too_large, 429 tools.job.too_many_active (unfinished jobs per owner), tools.quota.exceeded (Retry-After; the owner's allowance is spent) or a rate limit, 503 tools.job.unavailable, 503 tools.unavailable (a program or upstream the job type needs is missing), 503 tools.busy (Retry-After; every slot for this kind of work is taken). A tool that refuses its input after the job was accepted fails the job with its own code (413 tools.pdf.too_many_pages, 422 tools.pdf.wrong_password…; tools.job@1 error).",
"type": "object",
"required": [
"type"
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex e4438d63..5a2cf346 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -6880,7 +6880,7 @@ export interface ToolsJobFailedPayload {
error: {
status: number;
/**
-- * tools.job.failed, tools.job.timeout, or a code the tool chose (tools.…).
++ * tools.job.failed, tools.job.timeout, or a code the tool chose (tools.…: tools.pdf.too_many_pages, tools.pdf.wrong_password…).
*/
code: string;
/**
@@@ -7503,9 +7503,9 @@ export interface NetworkModuleUpdatedPayload {
};
}
--/** [email protected] (owner: tools) */
++/** [email protected] (owner: tools) */
/**
-- * One tool on openvibe.tools as its registry describes it (GET /api/v1/tools/:id, and each item of GET /api/v1/tools; capability tools.tool.read; ADR-027). There is one descriptor per catalogue tool (GET /api/catalog.json tools[].id), built from the tool's own code, so its page, the run API, openvibe-sdk/tools, the OpenAPI document and the docs all read the same facts. EXECUTION is where the tool's engine runs for its page: client (in the browser; nothing leaves it), sync (a server request answered inline) or job (an asynchronous job on a satellite, tools.job@1). API says whether POST /api/v1/tools/{id}/run exposes the tool. A client tool has api true only when it also has a server engine (a pure transform that runs in Node), and its page stays browser-only. A page-only tool (yt) has api false and run null. A job tool's run creates a job of run.job.type whose input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). INPUT is the JSON Schema (2020-12) that a run request's input must match, embedded or as { $ref: https://openvibe.tools/api/v1/tools/{id}/schema#/$defs/input }. GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, and the list uses the $ref form. RULES this schema enforces: api false has no run, and api true has a run and an input; an API job tool names its job, and only job tools name one; an API tool whose output is a file runs as a job (its results are served as job files); a tools.net.probe tool fetches (egress) and is never anonymous; an anonymous egress tool has a per-target throttle (limits.perTargetPerMinute); a client tool never fetches; an unavailable tool says why (statusReason); JSON output has a schema. contracts.tools.checkDescriptor(d) also checks what depends on the id (run.path, $ref targets) and files.min <= files.max.
++ * One tool on openvibe.tools as its registry describes it (GET /api/v1/tools/:id, and each item of GET /api/v1/tools; capability tools.tool.read; ADR-027). There is one descriptor per catalogue tool (GET /api/catalog.json tools[].id), built from the tool's own code, so its page, the run API, openvibe-sdk/tools, the OpenAPI document and the docs all read the same facts. EXECUTION is where the tool's engine runs for its page: client (in the browser; nothing leaves it), sync (a server request answered inline) or job (an asynchronous job on a satellite, tools.job@1). API says whether POST /api/v1/tools/{id}/run exposes the tool. A client tool has api true only when it also has a server engine (a pure transform that runs in Node), and its page stays browser-only. A page-only tool (yt) has api false and run null. A job tool's run creates a job of run.job.type whose input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). INPUT is the JSON Schema (2020-12) that a run request's input must match, embedded or as { $ref: https://openvibe.tools/api/v1/tools/{id}/schema#/$defs/input }. GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, and the list uses the $ref form. When api is false, $defs.input is false (the schema that accepts nothing), since there is no run to take an input. OUTPUT.SCHEMA describes result.data for every execution: inline and job tools alike. KEYWORDS (the catalogue's search terms) and EXAMPLES (sample runs for the docs and the OpenAPI document) are optional. NOT A TOOL: there is no planned status. A planned catalogue entry has no descriptor and is not listed, and neither is a mirror (a second build of another tool). GET /api/v1/tools/:id and /schema answer 404 tools.tool.not_found for either, with a detail that says the id is planned or names the tool it mirrors, as for an id that does not exist. RULES this schema enforces: api false has no run and no examples, and api true has a run and an input; an API job tool names its job, and only job tools name one; an API tool whose output is a file runs as a job (its results are served as job files); a tools.net.probe tool fetches (egress) and is never anonymous; an anonymous egress tool has a per-target throttle (limits.perTargetPerMinute); a client tool never fetches; an unavailable tool says why (statusReason); JSON output has a schema. contracts.tools.checkDescriptor(d) also checks what depends on the id (run.path, $ref targets), files.min <= files.max, and each example: its input against an embedded input schema and limits.maxInputBytes, and its files against files (count and accept).
*/
export interface ToolDescriptor {
/**
@@@ -7522,7 +7522,13 @@ export interface ToolDescriptor {
*/
summary: string;
/**
-- * stable: works, and its input and output only grow. beta: works; input or output may still change in a minor. preview: works with known gaps (limits or output not final). unavailable: listed, but runs are refused with 503 tools.tool.unavailable and the page says so (statusReason).
++ * Optional. The catalogue's search terms for the tool (GET /api/catalog.json tools[].keywords): phrases people search for, such as 'heic to png' or 'yaml validator'. GET /api/v1/tools?q= matches every word of the query against the id, name, summary and these.
++ *
++ * @maxItems 50
++ */
++ keywords?: string[];
++ /**
++ * stable: works, and its input and output only grow. beta: works; input or output may still change in a minor. preview: works with known gaps (limits or output not final). unavailable: listed, but runs are refused with 503 tools.tool.unavailable and the page says so (statusReason); a program or upstream the engine needs is missing (qpdf, pdftoppm, heif-dec, ffmpeg…), and the engine's own routes answer 503 tools.unavailable while it is. There is no planned status: a planned catalogue entry is not a tool, so it has no descriptor and is not listed.
*/
status: "stable" | "beta" | "preview" | "unavailable";
/**
@@@ -7571,7 +7577,7 @@ export interface ToolDescriptor {
legacy?: string[];
} | null;
/**
-- * JSON Schema (2020-12) of the run request's input: embedded (an object schema), or { $ref } to $defs/input of GET /api/v1/tools/:id/schema. null only for a tool without an API.
++ * JSON Schema (2020-12) of the run request's input: embedded (an object schema), or { $ref } to $defs/input of GET /api/v1/tools/:id/schema. null only for a tool without an API, whose $defs/input is false (it accepts nothing).
*/
input:
| {
@@@ -7598,13 +7604,47 @@ export interface ToolDescriptor {
*/
maxBytes: number;
} | null;
++ /**
++ * Optional. Sample runs, which the docs and the generated OpenAPI document publish. Each example's input is valid against the tool's input schema and fits limits.maxInputBytes, and its files fit files (count and accept); contracts.tools.checkDescriptor checks this whenever input is embedded. Only a tool with an API has examples.
++ *
++ * @maxItems 50
++ */
++ examples?: {
++ /**
++ * What the example shows, for people.
++ */
++ title?: string;
++ /**
++ * A run request's input (tools.run-request@1 input), valid against the tool's input schema.
++ */
++ input: {};
++ /**
++ * The files the sample run takes (uploaded parts or files references), in order: what each one is, and where to download a sample.
++ *
++ * @maxItems 100
++ */
++ files?: {
++ /**
++ * A file name for the sample (photo.jpg).
++ */
++ name?: string;
++ /**
++ * Its media type, one that files.accept allows.
++ */
++ mime: string;
++ /**
++ * A sample file to download.
++ */
++ url?: string;
++ }[];
++ }[];
output: {
/**
* json: result.data; text: result.text; file or files: result.files (job result files).
*/
kind: "json" | "file" | "files" | "text";
/**
-- * JSON Schema of result.data (kind json): embedded, or { $ref } to $defs/output of GET /api/v1/tools/:id/schema.
++ * JSON Schema of result.data, whatever the execution: for a job tool too, where it describes the data of the run's result and of its job's result (tools.job@1 result.data), which are the same object. Required when kind is json. Embedded, or { $ref } to $defs/output of GET /api/v1/tools/:id/schema.
*/
schema?:
| {
@@@ -7634,7 +7674,7 @@ export interface ToolDescriptor {
*/
maxPixels?: number;
/**
-- * Most PDF pages.
++ * Most PDF pages. A longer document fails with 413 tools.pdf.too_many_pages.
*/
maxPages?: number;
/**
@@@ -7660,7 +7700,7 @@ export interface ToolDescriptor {
capability: "tools.tool.run" | "tools.net.probe";
};
/**
-- * The quota bucket a run counts against, with allowances per caller tier. Names describe the work, never a tier or a price (tools-run, tools-job, tools-probe, tools-fetch…), so a paid tier later adds allowances, not names.
++ * The quota bucket a run counts against, with allowances per caller tier. Names describe the work, never a tier or a price (tools-run, tools-job, tools-probe, tools-fetch…), so a paid tier later adds allowances, not names. A caller whose allowance is spent gets 429 tools.quota.exceeded with Retry-After.
*/
quotaClass: string;
/**
@@@ -7685,7 +7725,7 @@ export interface ToolDescriptor {
/** [email protected] (owner: tools) */
/**
-- * The answer of GET /api/v1/tools on openvibe.tools (capability tools.tool.read; ADR-027): every tool's descriptor (tools.tool@1), with input and output schemas in the { $ref } form (GET /api/v1/tools/:id has them embedded). Filters: ?family=, ?execution=client|sync|job, ?api=true|false, ?status=, ?q= (name, summary and keywords). Planned placeholders are not tools and are not listed. Cacheable (ETag); updated_at changes when any descriptor does.
++ * The answer of GET /api/v1/tools on openvibe.tools (capability tools.tool.read; ADR-027): every tool's descriptor (tools.tool@1), with input and output schemas in the { $ref } form (GET /api/v1/tools/:id has them embedded). Filters: ?family=, ?execution=client|sync|job, ?api=true|false, ?status=, ?q= (every word must appear in the id, name, summary or keywords); family, execution and status take comma lists. An unknown execution or status value, or an api that is not true or false, is 400 tools.query.invalid. Every listed tool has one of the descriptor's statuses: there is no planned status, so planned placeholders are not tools and are not listed, and neither are mirrors (GET /api/v1/tools/:id answers 404 tools.tool.not_found for both, with a detail saying why). Cacheable (ETag); updated_at changes when any descriptor does.
*/
export interface ToolList {
tools: ToolDescriptor[];
@@@ -7706,7 +7746,7 @@ export interface ToolList {
/** [email protected] (owner: tools) */
/**
-- * Body of POST /api/v1/tools/{id}/run on openvibe.tools (capability tools.tool.run, or tools.net.probe for network probes; ADR-027). JSON, or multipart/form-data like POST /api/v1/jobs: the text parts input (JSON text), wait_ms and idempotency_key, and uploaded files as file or files parts. The tool gets the uploaded files first, then the files references, in order; the count must fit the descriptor's files.min and files.max. input must match the tool's input schema (tools.tool@1 input). A job tool's job input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). The Idempotency-Key header wins over idempotency_key; ?wait_ms= on the query does the same as the field. Answers tools.run@1: 200 finished (succeeded, or failed with the tool's own error), 202 + Location while a job is queued or running, 200 + Idempotent-Replayed: true for a replay. Refusals are problem+json: 400 tools.run.invalid (not JSON, a bad field, wrong file count), 401 token.*, 403 capability.denied, 404 tools.tool.not_found, 404 tools.tool.not_runnable (api false: a page-only tool), 404 tools.run.file_not_found (a reference the caller cannot read), 409 tools.job.idempotency_conflict, 413 tools.file.too_large, 415 tools.file.unsupported_type (checked against the bytes), 422 tools.input.invalid (errors[] with JSON pointers), 429 quota.exceeded or tools.job.too_many_active (Retry-After), 503 tools.tool.unavailable.
++ * Body of POST /api/v1/tools/{id}/run on openvibe.tools (capability tools.tool.run, or tools.net.probe for network probes; ADR-027). JSON, or multipart/form-data like POST /api/v1/jobs: the text parts input (JSON text), wait_ms and idempotency_key, and uploaded files as file or files parts. The tool gets the uploaded files first, then the files references, in order; the count must fit the descriptor's files.min and files.max. input must match the tool's input schema (tools.tool@1 input). A job tool's job input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). The Idempotency-Key header wins over idempotency_key; ?wait_ms= on the query does the same as the field. Answers tools.run@1: 200 finished (succeeded, or failed with the tool's own error), 202 + Location while a job is queued or running, 200 + Idempotent-Replayed: true for a replay. Refusals are problem+json: 400 tools.run.invalid (not JSON, a bad field, wrong file count), 401 token.*, 403 capability.denied, 404 tools.tool.not_found, 404 tools.tool.not_runnable (api false: a page-only tool), 404 tools.run.file_not_found (a reference the caller cannot read), 409 tools.job.idempotency_conflict, 413 tools.file.too_large, 415 tools.file.unsupported_type (checked against the bytes), 422 tools.input.invalid (errors[] with JSON pointers), 429 tools.quota.exceeded (Retry-After; the caller's allowance in the tool's quota class is spent), quota.exceeded or tools.job.too_many_active (Retry-After), 503 tools.tool.unavailable (the descriptor's status is unavailable), 503 tools.unavailable (a program or upstream the tool needs is missing), 503 tools.busy (Retry-After; every slot for this kind of work is taken). A job tool's own refusals (413 tools.pdf.too_many_pages, 422 tools.pdf.wrong_password…) come back as a failed run (tools.run@1 error).
*/
export interface ToolsRunRequest {
/**
@@@ -7739,7 +7779,7 @@ export interface ToolsRunRequest {
/** [email protected] (owner: tools) */
/**
-- * The answer of POST /api/v1/tools/{id}/run (tools.run-request@1; ADR-027). Finished: { state: succeeded, tool, result, took_ms } or { state: failed|cancelled, tool, error, took_ms }, with job set when the tool ran as a job. Not finished within wait_ms: { state: queued|running, tool, job, location }, sent with 202 and a Location header; follow the job there (GET, its events, its files; tools.job@1). result carries data (kind json, matching the descriptor's output schema), text (kind text) or files (kind file or files: the job's result files, downloaded from their url). took_ms runs from the request to the answer for an inline run, and from created_at to finished_at for a job. error is problem+json from the tool: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…). A request refused before the tool ran is a problem+json answer, not a run.
++ * The answer of POST /api/v1/tools/{id}/run (tools.run-request@1; ADR-027). Finished: { state: succeeded, tool, result, took_ms } or { state: failed|cancelled, tool, error, took_ms }, with job set when the tool ran as a job. Not finished within wait_ms: { state: queued|running, tool, job, location }, sent with 202 and a Location header; follow the job there (GET, its events, its files; tools.job@1). result carries data (kind json, matching the descriptor's output schema), text (kind text) or files (kind file or files: the job's result files, downloaded from their url). took_ms runs from the request to the answer for an inline run, and from created_at to finished_at for a job. error is problem+json from the tool: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), such as 413 tools.pdf.too_many_pages or 422 tools.pdf.wrong_password; its status is the problem's, not the HTTP answer's. A request refused before the tool ran is a problem+json answer, not a run.
*/
export type ToolsRun =
| {
@@@ -7815,7 +7855,7 @@ export type ToolsRun =
/** [email protected] (owner: tools) */
/**
-- * A Tools job as the jobs API answers it (OpenVibe.Tools apps/_shared/jobs/http.js, system.view; ADR-027): POST /api/v1/jobs (202 + Location, or 200 + Idempotent-Replayed: true), GET and DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT and DELETE /api/v1/jobs/:id/references/:ref, the data of every SSE event on GET /api/v1/jobs/:id/events, and the job member of tools.run@1. A job is visible only to its owner (a person, a service/app principal, or one browser session); anyone else gets the same 404 tools.job.not_found as for a job that does not exist. result is null until the job succeeded; each result file is downloaded from its url (GET /api/v1/jobs/:id/files/:n, ?inline=1 for previews) and is stored as a private OpenVibe.Media object (storage media) or on the satellite until the job expires (storage local). expires_at is null while a reference keeps the result. error is problem+json: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), with server paths removed.
++ * A Tools job as the jobs API answers it (OpenVibe.Tools apps/_shared/jobs/http.js, system.view; ADR-027): POST /api/v1/jobs (202 + Location, or 200 + Idempotent-Replayed: true), GET and DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT and DELETE /api/v1/jobs/:id/references/:ref, the data of every SSE event on GET /api/v1/jobs/:id/events, and the job member of tools.run@1. A job is visible only to its owner (a person, a service/app principal, or one browser session); anyone else gets the same 404 tools.job.not_found as for a job that does not exist. result is null until the job succeeded; each result file is downloaded from its url (GET /api/v1/jobs/:id/files/:n, ?inline=1 for previews) and is stored as a private OpenVibe.Media object (storage media) or on the satellite until the job expires (storage local). expires_at is null while a reference keeps the result. error is problem+json: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), such as 413 tools.pdf.too_many_pages or 422 tools.pdf.wrong_password, with server paths removed.
*/
export interface ToolsJob {
id: string;
@@@ -7935,7 +7975,7 @@ export interface ToolsJob {
/** [email protected] (owner: tools) */
/**
-- * Body of POST /api/v1/jobs on the Tools satellites that run jobs (img, audio, docs; capability tools.job.create; OpenVibe.Tools apps/_shared/jobs/http.js). JSON, or multipart/form-data with the same fields as text parts (input as JSON text) and the input files as file or files parts. The Idempotency-Key header wins over idempotency_key: the same (owner, key) and request returns the same job (200 + Idempotent-Replayed: true), a different request under it is 409 tools.job.idempotency_conflict. Types: img.process (input { tool: convert|compress|resize|crop, … }, one image), audio.process ({ tool, … } as /api/process takes them, one audio or video file), docs.process ({ tool, … }, one PDF, or several files for merge and img2pdf). A format host fills in its format (webp.openvibe.tools converts to WebP). Answers tools.job@1 (202 + Location). Refusals are problem+json: 400 tools.job.invalid (input not a JSON object or over 16 KB, wrong file count, the type's own validation) or tools.job.unknown_type, 401 token.* or tools.job.no_owner, 403 capability.denied, 413 tools.job.too_large, 429 tools.job.too_many_active (unfinished jobs per owner) or a rate limit, 503 tools.job.unavailable.
++ * Body of POST /api/v1/jobs on the Tools satellites that run jobs (img, audio, docs; capability tools.job.create; OpenVibe.Tools apps/_shared/jobs/http.js). JSON, or multipart/form-data with the same fields as text parts (input as JSON text) and the input files as file or files parts. The Idempotency-Key header wins over idempotency_key: the same (owner, key) and request returns the same job (200 + Idempotent-Replayed: true), a different request under it is 409 tools.job.idempotency_conflict. Types: img.process (input { tool: convert|compress|resize|crop, … }, one image), audio.process ({ tool, … } as /api/process takes them, one audio or video file), docs.process ({ tool, … }, one PDF, or several files for merge and img2pdf). A format host fills in its format (webp.openvibe.tools converts to WebP). Answers tools.job@1 (202 + Location). Refusals are problem+json: 400 tools.job.invalid (input not a JSON object or over 16 KB, wrong file count, the type's own validation) or tools.job.unknown_type, 401 token.* or tools.job.no_owner, 403 capability.denied, 413 tools.job.too_large, 429 tools.job.too_many_active (unfinished jobs per owner), tools.quota.exceeded (Retry-After; the owner's allowance is spent) or a rate limit, 503 tools.job.unavailable, 503 tools.unavailable (a program or upstream the job type needs is missing), 503 tools.busy (Retry-After; every slot for this kind of work is taken). A tool that refuses its input after the job was accepted fails the job with its own code (413 tools.pdf.too_many_pages, 422 tools.pdf.wrong_password…; tools.job@1 error).
*/
export interface ToolsJobRequest {
/**
ddiff --git a/lib/tools.js b/lib/tools.js
iindex eb164252..b6d7d494 100644
---- a/lib/tools.js
++++ b/lib/tools.js
@@@ -4,10 +4,12 @@
* these checks add the ones that depend on the tool's id or compare two values, so a registry (and its
* tests) can hold every descriptor to the same bar:
*
-- * contracts.tools.checkDescriptor(d) -> { valid, errors: [{ path, message }] }
++ * contracts.tools.checkDescriptor(d) -> { valid, errors: [{ path, message }] }, examples checked too
* contracts.tools.checkList(list) -> the same for a tools.tool-list@1 answer, every item included
* contracts.tools.jobInput(d, input) -> the job input a job tool's run submits
*/
++const Ajv2020 = require('ajv/dist/2020');
++const addFormats = require('ajv-formats');
const registry = require('./registry');
const SCHEMA_REF = /\/api\/v1\/tools\/([a-z][a-z0-9-]{0,39})\/schema#\/\$defs\/(input|output)$/;
@@@ -16,6 +18,48 @@ const SCHEMA_REF = /\/api\/v1\/tools\/([a-z][a-z0-9-]{0,39})\/schema#\/\$defs\/(
const TIER_WORDS = /(^|-)(free|paid|pro|plus|premium|trial|tier|anon|anonymous|session|user|app|service|sandbox)(-|$)/;
const isRefOnly = (s) => !!s && typeof s.$ref === 'string' && Object.keys(s).length === 1;
++const accepts = (accept, mime) => accept === mime || (accept.endsWith('/*') && mime.startsWith(accept.slice(0, -1)));
++
++// A tool's own input schema, compiled once per distinct schema to check its examples. It is the
++// tool's JSON Schema, not a contract, so it gets its own lenient validator; its $id is dropped so two
++// descriptors naming the same $id never clash.
++let inputAjv = null;
++const inputValidators = new Map();
++const MAX_INPUT_VALIDATORS = 500;
++function inputValidator(schema) {
++ const key = JSON.stringify(schema);
++ let v = inputValidators.get(key);
++ if (!v) {
++ if (!inputAjv) { inputAjv = new Ajv2020({ allErrors: true, strict: false, allowUnionTypes: true, logger: false }); addFormats(inputAjv); }
++ const own = { ...schema };
++ delete own.$id;
++ try { v = { fn: inputAjv.compile(own), schema: own }; } catch (e) { v = { error: e.message }; }
++ if (inputValidators.size >= MAX_INPUT_VALIDATORS) {
++ const [oldKey, old] = inputValidators.entries().next().value;
++ inputValidators.delete(oldKey);
++ if (old.schema) inputAjv.removeSchema(old.schema);
++ }
++ inputValidators.set(key, v);
++ }
++ return v;
++}
++
++/** An example runs as it says: its input matches an embedded input schema and maxInputBytes, its files fit files. */
++function checkExamples(d, err) {
++ if (!d.examples) return;
++ const check = d.input && !isRefOnly(d.input) ? inputValidator(d.input) : null;
++ if (check && check.error) err('/input', `does not compile, so its examples cannot be checked: ${check.error}`);
++ d.examples.forEach((ex, i) => {
++ const at = `/examples/${i}`;
++ if (check && check.fn && !check.fn(ex.input)) for (const e of check.fn.errors) err(`${at}/input${e.instancePath}`, `${e.message} (the tool's input schema)`);
++ const bytes = Buffer.byteLength(JSON.stringify(ex.input));
++ if (d.limits.maxInputBytes && bytes > d.limits.maxInputBytes) err(`${at}/input`, `is ${bytes} bytes as JSON, over limits.maxInputBytes (${d.limits.maxInputBytes})`);
++ if (!ex.files) return;
++ if (!d.files) { if (ex.files.length) err(`${at}/files`, 'the tool takes no files'); return; }
++ if (ex.files.length < d.files.min || ex.files.length > d.files.max) err(`${at}/files`, `${ex.files.length} files; the tool takes ${d.files.min} to ${d.files.max}`);
++ ex.files.forEach((f, j) => { if (!d.files.accept.some(a => accepts(a, f.mime))) err(`${at}/files/${j}/mime`, `${f.mime} is not in files.accept`); });
++ });
++}
/** Every rule for one descriptor: the schema's, then the ones that depend on its id. */
function checkDescriptor(d) {
@@@ -32,6 +76,7 @@ function checkDescriptor(d) {
if (!m || m[1] !== d.id || m[2] !== want) err(`${at}/$ref`, `must point at /api/v1/tools/${d.id}/schema#/$defs/${want}`);
}
if (TIER_WORDS.test(d.quotaClass)) err('/quotaClass', 'names the work, never a caller tier or a price');
++ checkExamples(d, err);
return { valid: errors.length === 0, errors };
}
ddiff --git a/manifests/capabilities/tools.tool.read.json b/manifests/capabilities/tools.tool.read.json
iindex 778a0415..aa799bf5 100644
---- a/manifests/capabilities/tools.tool.read.json
++++ b/manifests/capabilities/tools.tool.read.json
@@@ -2,9 +2,9 @@
"id": "tools.tool.read",
"version": "1.0",
"owner": "tools",
-- "status": "planned",
++ "status": "active",
"visibility": "public",
-- "description": "Read the tool registry (ADR-027): every tool's descriptor (tools.tool@1: execution, whether the run API exposes it, how to call it, input and output schemas, files, limits, auth, quota class, cost, egress, hosts, docs), filtered by family, execution, api, status or text. GET /api/v1/tools answers tools.tool-list@1 with schemas as { $ref }; GET /api/v1/tools/:id has them embedded; GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }. Open to everyone, anonymous callers included; cacheable. Planned until the gateway serves these routes.",
++ "description": "Read the tool registry (ADR-027): every tool's descriptor (tools.tool@1: execution, whether the run API exposes it, how to call it, input and output schemas, files, limits, auth, quota class, cost, egress, hosts, docs), filtered by family, execution, api, status or text. GET /api/v1/tools answers tools.tool-list@1 with schemas as { $ref }; GET /api/v1/tools/:id has them embedded; GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, with $defs.input false for a tool without an API. The gateway (https://openvibe.tools) answers for every tool, and each satellite for its own tools with its own live status. Open to everyone, anonymous callers included, from any origin (CORS *); cacheable (ETag, 304). A bad filter value is 400 tools.query.invalid; an unknown, planned or mirror id is 404 tools.tool.not_found, whose detail says which.",
"outputSchema": "tools.tool@1",
"permissions": [
"tools:catalog:read"
ddiff --git a/manifests/services/tools.json b/manifests/services/tools.json
iindex f1f75df5..19bd4722 100644
---- a/manifests/services/tools.json
++++ b/manifests/services/tools.json
@@@ -1,5 +1,5 @@
{
-- "version": "0.4.0",
++ "version": "0.4.1",
"contractRanges": {
"openvibe-contracts": ">=0.7.0 <1.0.0"
},
@@@ -31,5 +31,5 @@
"namespacesOwned": [
"tools.*"
],
-- "notes": "Public at openvibe.tools: 169 tools in 9 families on the gateway (port 4001) and its satellites, catalogue at GET /api/catalog.json (re-served by Network). Asynchronous jobs (Wave 11) run on the img, audio and docs satellites only (POST /api/v1/jobs, GET/DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT|DELETE /api/v1/jobs/:id/references/:ref, SSE events, result files); the gateway has no /api/v1/jobs yet. Job results are stored as private Media objects (TOOLS_JOB_RESULTS=media: Tools holds media.object.upload and media.object.read, namespace tools) and job lifecycle events tools.job.created|started|succeeded|failed are published to OpenVibe.Events through an outbox in each satellite's jobs.db (EVENTS_URL set; events.event.publish). Planned (ADR-027): the tool registry GET /api/v1/tools[/:id[/schema]] (tools.tool.read), the uniform run API POST /api/v1/tools/:id/run (tools.tool.run; network probes need tools.net.probe), a jobs facade on the gateway, tiered quotas and a pseudonymous abuse log; the legacy /api/process, /api/net/* and /api/dev/* routes keep working with Deprecation and Sunset (2026-12-31) headers. GET /api/ready is truthful on the gateway (it checks every satellite) and on each satellite."
++ "notes": "Public at openvibe.tools: 169 tools in 9 families on the gateway (port 4001) and its satellites, catalogue at GET /api/catalog.json (re-served by Network). Asynchronous jobs (Wave 11) run on the img, audio and docs satellites only (POST /api/v1/jobs, GET/DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT|DELETE /api/v1/jobs/:id/references/:ref, SSE events, result files); the gateway has no /api/v1/jobs yet. Job results are stored as private Media objects (TOOLS_JOB_RESULTS=media: Tools holds media.object.upload and media.object.read, namespace tools) and job lifecycle events tools.job.created|started|succeeded|failed are published to OpenVibe.Events through an outbox in each satellite's jobs.db (EVENTS_URL set; events.event.publish). The tool registry (ADR-027, tools.tool.read) is served: GET /api/v1/tools[/:id[/schema]] on the gateway, for all 169 tools, and on the img, audio, docs, text, yt, maps and food satellites for their own. Planned (ADR-027): the uniform run API POST /api/v1/tools/:id/run (tools.tool.run; network probes need tools.net.probe), a jobs facade on the gateway, tiered quotas and a pseudonymous abuse log; the legacy /api/process, /api/net/* and /api/dev/* routes keep working with Deprecation and Sunset (2026-12-31) headers. GET /api/ready is truthful on the gateway (it checks every satellite) and on each satellite."
}
ddiff --git a/test/run.js b/test/run.js
iindex f4e7f5d3..500931ec 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -29,13 +29,17 @@ for (const d of contracts.catalog.length ? JSON.parse(fs.readFileSync(path.join(
}
// ── Fixtures: every contract has examples that pass and counter-examples that fail ──
++// A contract with a helper that holds it to more than its schema is checked with that helper, so
++// its invalid fixtures can break those rules too (a tool example whose input its own schema refuses).
++const FULL_CHECK = { 'tools.tool': (v) => contracts.tools.checkDescriptor(v), 'tools.tool-list': (v) => contracts.tools.checkList(v) };
for (const c of contracts.catalog) {
for (const kind of ['valid', 'invalid']) {
const dir = path.join(ROOT, 'fixtures', c.id, kind);
const list = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => f.endsWith('.json')) : [];
ok(list.length > 0, `${c.id} has ${kind} fixtures`);
for (const f of list) {
-- const r = contracts.validate(c.id, JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
++ const value = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
++ const r = FULL_CHECK[c.id] ? FULL_CHECK[c.id](value) : contracts.validate(c.id, value);
ok(r.valid === (kind === 'valid'), `${c.id} ${kind}/${f}: ${r.valid ? 'passed' : JSON.stringify(r.errors)}`);
}
}
@@@ -256,6 +260,49 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
ok(!tools.checkList({ ...list, tools: [list.tools[0], { ...list.tools[1], hosts: [list.tools[0].hosts[0]] }], count: 2, families: undefined }).valid, 'a host belongs to one tool');
ok(!tools.checkList({ ...list, families: [{ id: 'img', name: 'Image Tools', count: 2 }] }).valid, 'family counts are honest');
ok(!tools.checkList({ ...list, tools: [{ ...list.tools[0], run: { ...list.tools[0].run, path: '/api/v1/tools/jpg/run' } }], count: 1, families: undefined }).valid, 'checkList applies checkDescriptor to every tool');
++
++ // v0.33.1: keywords and examples are optional, so a v0.33.0 descriptor stays valid; every example
++ // runs as it says (checkDescriptor checks its input when the input schema is embedded).
++ const bare = (d) => Object.fromEntries(Object.entries(d).filter(([k]) => k !== 'keywords' && k !== 'examples'));
++ for (const d of valid) ok(D(bare(d)), `${d.id}: a descriptor without keywords or examples (v0.33.0) is still valid`);
++ ok(valid.some(d => d.keywords && d.keywords.length) && valid.some(d => d.examples && d.input && !d.input.$ref), 'valid fixtures carry keywords and examples with an embedded input');
++ ok(list.tools.some(d => d.keywords), 'the list carries keywords, which ?q= matches');
++ const pngBad = tools.checkDescriptor({ ...png, examples: [{ input: { compressionLevel: 12 } }] });
++ ok(!pngBad.valid && pngBad.errors.some(e => e.path === '/examples/0/input/compressionLevel'), `an example whose input the tool's schema refuses fails, pointing at it: ${JSON.stringify(pngBad.errors)}`);
++ ok(!D({ ...byId.jsonminify, examples: [{ input: {} }] }), 'an example missing a required input field fails');
++ ok(!D({ ...byId.jsonminify, examples: [{ input: { text: '[]', extra: 1 } }] }), 'an example with a field the input schema does not allow fails');
++ ok(D({ ...dns, examples: [{ input: { anything: true } }] }), 'an input given as $ref cannot be checked here, so its examples pass (the Tools registry checks them with the schema embedded)');
++ ok(!D({ ...byId.jsonminify, limits: { ...byId.jsonminify.limits, maxInputBytes: 20 }, examples: [{ input: { text: 'x'.repeat(40) } }] }), 'an example fits limits.maxInputBytes');
++ ok(!D({ ...png, examples: [{ input: {}, files: [] }] }) && !D({ ...png, examples: [{ input: {}, files: [{ mime: 'image/png' }, { mime: 'image/png' }] }] }), 'example files fit files.min and files.max');
++ ok(D({ ...png, examples: [{ input: {}, files: [{ mime: 'image/heic' }] }] }) && !D({ ...png, examples: [{ input: {}, files: [{ mime: 'text/plain' }] }] }), 'example files are types files.accept allows (image/* takes image/heic)');
++ ok(!D({ ...dns, examples: [{ input: {}, files: [{ mime: 'text/plain' }] }] }), 'a tool that takes no files has no example files');
++ ok(!T({ ...yt, examples: [{ input: {} }] }), 'a tool without an API has no examples');
++ ok(!T({ ...png, examples: [{ title: 'no input' }] }) && !T({ ...png, examples: [{ input: {}, output: {} }] }), 'each example is an input, with an optional title and files');
++ ok(!T({ ...png, keywords: ['png', 'png'] }) && !T({ ...png, keywords: [''] }) && !T({ ...png, keywords: ['trailing '] }) && T({ ...byId.png, keywords: ['curl -I online'] }), 'keywords are unique, trimmed, non-empty phrases');
++ const bogus = tools.checkDescriptor({ ...byId.jsonminify, input: { type: 'object', properties: { text: { type: 'nope' } } }, examples: [{ input: { text: 'x' } }] });
++ ok(!bogus.valid && bogus.errors.some(e => e.path === '/input'), 'an embedded input schema that does not compile is reported when there are examples to check');
++ ok(D({ ...byId.jsonminify, input: { ...byId.jsonminify.input, $id: 'https://openvibe.tools/api/v1/tools/jsonminify/schema' }, examples: [{ input: { text: '{}' } }] }) && D({ ...byId.port, input: { ...byId.port.input, $id: 'https://openvibe.tools/api/v1/tools/jsonminify/schema' }, examples: [{ input: { host: 'a.example', ports: [1] } }] }), 'input schemas that carry the same $id do not clash');
++
++ // v0.33.1: the registry is served (tools.tool.read active); the run API and probes are not yet.
++ ok(readCap.status === 'active' && runCap.status === 'planned' && probeCap.status === 'planned', 'tools.tool.read is active; tools.tool.run and tools.net.probe stay planned until the run API ships');
++ const statuses = contracts.schema('tools.tool').properties.status.enum;
++ ok(!statuses.includes('planned') && /no planned status/.test(contracts.schema('tools.tool').description) && /planned placeholders are not tools and are not listed/.test(contracts.schema('tools.tool-list').description), 'there is no planned status: planned entries are not tools, and the contracts say so');
++
++ // Problem codes Tools emits, listed where the routes that answer them are described.
++ const codeRe = new RegExp(contracts.schema('errors.problem').properties.code.pattern);
++ const said = [
++ ...['tools.tool', 'tools.tool-list', 'tools.run-request', 'tools.run', 'tools.job', 'tools.job-request'].map(id => JSON.stringify(contracts.schema(id))),
++ ...['tools.tool.read', 'tools.tool.run', 'tools.job.create'].map(id => capabilities.get(id).description),
++ ].join('\n');
++ const codes = { 'tools.unavailable': 503, 'tools.pdf.too_many_pages': 413, 'tools.pdf.wrong_password': 422, 'tools.query.invalid': 400, 'tools.tool.not_found': 404, 'tools.quota.exceeded': 429, 'tools.busy': 503 };
++ for (const [code, status] of Object.entries(codes)) {
++ ok(codeRe.test(code) && contracts.validate('errors.problem@1', { type: `https://openvibe.network/problems/${code}`, title: 'x', status, code }).valid, `${code} is a valid problem code`);
++ ok(said.includes(`${status} ${code}`), `${status} ${code} is listed in the Tools contracts`);
++ }
++ for (const id of ['tools.run-request', 'tools.job-request']) {
++ const text = contracts.schema(id).description;
++ ok(/tools\.quota\.exceeded \(Retry-After;/.test(text) && /503 tools\.busy \(Retry-After;/.test(text), `${id}: tools.quota.exceeded and tools.busy carry Retry-After`);
++ }
}
// ── Ids ──────────────────────────────────────────────────────────────────