# Task Plugin API v1 Task Plugin v1 has two independent entry surfaces. `meta.routes` registers plugin-owned native URLs; `meta.protocols` claims host-owned protocols without registering or copying their URLs. `apiVersion` remains `1`. ## Manifest ```js export const meta = { apiVersion: 1, key: "vendor", name: "Vendor", version: "1.0.0", author: {name: "Author"}, description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"}, models: ["vendor-model"], fetchMode: "per_task", routes: [ {method: "POST", path: "/vendor/v1/jobs", type: "submit", decode: "createJob", render: "jobCreated"}, {method: "GET", path: "/vendor/v1/jobs/:task_id", type: "query", render: "jobStatus"}, ], protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}, "openai_video"], }; ``` `submit` and `dynamic` routes require `decode` and `render`. `query` routes require `render`, prohibit `decode`, and use `taskIdParam` (default `task_id`). Names refer only to callable members of `native`. Route conflicts use method plus normalized path shape and the route index is published atomically with its plugin generation. `routes[].models` optionally restricts a `submit` or `dynamic` route to a subset of `meta.models`. The host matches the canonical top-level `model` body field before any JS hook runs; a missing, non-string, or unlisted model is rejected with 400 (plugin routes are exclusive paths, so there is no relay fallback). Declare it only when the model lives at the body top level — vendor formats that nest the model elsewhere should omit `models` and filter inside `decode`. `query` routes have no body and reject the field. Each `protocols` entry claims a host protocol. A protocol that defines modes must be claimed in object form with an explicit `supports`; the bare-string form is valid only for protocols without modes (`openai_video`). `supports` declares which client request forms the plugin accepts on `openai_responses`: `"stream"` (`stream: true`), `"sync"` (neither flag; the call blocks until the terminal Response), `"background"` (`background: true`; the create returns a pending Response immediately). An unsupported request form is rejected with a 400 at channel selection, before any plugin hook runs and before billing. Retrieval (`GET /v1/responses/:response_id`) is not a mode: every created response is always retrievable. `{name, models}` still narrows that protocol's endpoint bindings to a subset of `meta.models` and composes with `supports`. Unlisted models never enter the plugin on that protocol path — they fall through to the built-in Go relay. Cross-plugin endpoint conflicts are judged on the narrowed set, so two plugins may share one protocol path by claiming disjoint model sets. Enabled uploads pre-flight the candidate against the live routing generation and reject the first channel-type, native-route, or protocol-model conflict (the error names the counterpart plugin). Set `force: true` or `enabled: false` to store the plugin anyway. `endpoints`, `routes[].renderer`, global `resolveRequest`, global `renderError`, and global `renderers` are rejected. `parseSubmitResponse` returns only `{taskId, taskData}` (plus the documented lifecycle fields); `clientResponse` is rejected. `icon` is an optional LobeHub icon name string (for example `Sora.Color`). The values `text` and `text: