Skip to main content

plugin-specify

Write the specification.md for a Resonate transport plugin — the implementation source for plugins/<name>/spec/specification.md.

Jump to install

Source facts

Repository
resonatehq/resonate-plugins
Last source activity
August 30, 2026 at 07:24
Detected SKILL.md language
English
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
2 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
plugin-specify
description
Write the specification.md for a Resonate transport plugin — the implementation source for plugins/<name>/spec/specification.md.
# plugin-specify Read [plugin](../plugin) first — the goal, the approach, the algebra — and `plugins/<name>/spec/preparation.md`, which decided this plugin's surface and gave each operation its algebra. Where the provider's documentation contradicts the preparation, the documentation wins and you say so in your final message; where you merely disagree with it, follow it — the surface was decided as a design, not guessed here. Write `plugins/<name>/spec/specification.md` for one provider. The document is an implementation instruction for a coding agent. It contains tables, request lines, JSON Schemas, and Python — no prose. Every claim must come from the provider's live documentation, fetched now. Do not use memorized API knowledge: APIs change. ## Concepts A plugin exposes a provider's API as durable promises, so that any Resonate SDK can call it as if it were a locally defined function — no client library, no HTTP in the caller's code, no integration written once per language. That is what a plugin is for, and it is worth doing for an API whose every call answers in one round trip. Where an action is long-running, the promise carries await semantics on top for free: the caller awaits, the plugin sees the action through to its terminal state, and nothing in the caller's process has to stay alive meanwhile. That is the icing. It is not the qualification — a document that specifies three fast reads accurately is a good plugin, and hunting for something slow to justify one is how a plugin ends up specifying operations nobody asked for. The plugin function `op(cfg, promise)` runs in the Resonate server when the promise's Execute message is delivered. Its job is to complete the operation: begin it, poll it to a terminal state where it has one, and settle. - `promise.id` — the promise id (string). - `promise.timeout_at` — the caller's `timeoutAt`, milliseconds since epoch. - `promise.param["args"]` — the caller's arguments. - `cfg` — the §2 configuration, fully resolved before the plugin runs: defaults applied, `= poll` cascaded, `= instance` filled from the address by the config loader. - `sanitize(promise.id)` — engine-provided, deterministic. It yields `<up to 100 chars of the promise id, every character outside [A-Za-z0-9._-] replaced by _>-<16 hex chars>`: ASCII, `[A-Za-z0-9._-]` only, 17–117 characters. It does not collapse `..`, does not lower-case, and is not a fixed-width digest. Every value injected into the provider — idempotency keys, correlation fields, client-supplied ids — is `sanitize(promise.id)`, never the raw id. Same promise ⇒ same token, so dedup and lookups keep working. If the provider's constraint on the injected identity is tighter than the yield above (length cap below 117, hex-only, no dots, case-folded), the operation cannot use `sanitize` as the injected identity — say so in the Idempotency row and take a lower Invocation rung. In §4.N.5 Python, `sanitize`, `cfg`, and `promise` are ambient — no import, no definition. `cfg.<key>` is the §2 value; a `Duration` key is a `timedelta`, so cadences are written `cfg.poll.total_seconds()`. A key declared `Option<Duration>` with default `= poll` is read as the already-cascaded `cfg.poll_<op>`, never `cfg.poll_x or cfg.poll`. Mapping notation, used in schema `description` fields and URL templates: `= promise.param.x` (copied from args), `= sanitize(promise.id)` (injected identity), `= response.body.x` (projected from the provider response), `{?a,b,c = promise.param.*}` (the listed query parameters, each copied from the same-named arg, all optional), `= poll` (defaults to the `poll` config key), `= instance` (defaults to the address instance name). ## Outcomes Every operation ends in exactly one of four outcomes: one success verdict, one failure verdict, and two non-verdicts. Verdicts settle the promise and are RETURNED; non-verdicts leave it unsettled and are RAISED. Failures are classified by what would have to change for the same request to succeed: - *nothing can* → `return ("rejected", value)`. Value matches the Rejected schema. The request is a deterministic function of the durable promise — every redelivery sends identical bytes — so a failure that depends only on the request can never heal, and a terminal non-success state never un-happens. Reject when the provider documents the status as a property of the request (validation error, unknown resource) or reports a terminal non-success state of the work. - *time alone* → `raise Exception("release", reason)`. Retry right away: the failure is about the moment (rate limit, server error, network). The server re-delivers and re-entry must be safe. Any exception that is not a halt (library errors, network failures) is also treated as a release. - *a person, outside this system* → `raise Exception("halt", reason)`. Retry only after an operator acts: the failure is about our standing (credentials rejected, payment required, permission denied) — retrying changes nothing until a person fixes it, and hammering the provider meanwhile is noise. Re-delivery pauses until the operator continues the task. A halted promise still times out at `timeout_at`. Tie-breaks: - **Halt requires a documented status.** Raise halt only where the provider's documentation for that endpoint names a status meaning credentials rejected, payment required, or permission denied. A status the docs do not name is not halt. Never infer halt from body text. - **Waiting is not a person.** If the condition clears on its own within any plausible `timeoutAt` — rate limit, burst cap, maintenance window, a quota that resets on a documented cycle — it is release, not halt, even at 402/403. Halt is for a condition that only a completed billing action, a key rotation, or a role grant ends. - **A refreshable credential is not a standing problem.** Where authentication is multi-step (§3) and the token has a documented lifetime, a 401 inside a poll loop re-exchanges the token and continues; a 401 from the token exchange itself is halt. - **Every terminal non-success state is a rejection with its own code.** Enumerate the provider's terminal states in 4.N.4's status enum. One (or a named set) resolves; each remaining terminal state gets a distinct Rejected `code` (`run_failed`, `cancelled`, `expired`) — never folded into `invalid_request`. A state that is non-terminal but can persist indefinitely (paused, awaiting input) stays in the loop until `timeout_at`; say so in the Param description. The halt/release split is provider-wide and lives in one module-level `_check(r)` helper, defined in the first Implementation block: ```python def _check(r): # Halt statuses: only those this provider's docs name as # operator-required. Adjust the tuple per the documentation. if r.status_code in (401, 403): raise Exception("halt", r.text) if r.status_code == 429 or r.status_code >= 500: raise Exception("release", r.text) return r ``` Rejection is per-endpoint and written inline at every call site — the permanent statuses and their `code`s are facts about that one call, and a documented status may move class (a 403 that means "no access to this template" is rejected, not halt; handle it inline before `_check`). The call-site pattern: documented statuses first, `_check`, then the deterministic residue: ```python r = _check(requests.post(..., timeout=10)) if r.status_code == 409: ... # documented: e.g. the recovery path if r.status_code == 404: return ("rejected", {"code": "not_found", "detail": r.text}) if r.status_code >= 400: # Residue: identical bytes every redelivery — permanent. return ("rejected", {"code": "invalid_request", "detail": r.text}) ``` ## Procedure 1. Fetch the provider's API documentation. Find the OpenAPI spec if one is published; verify the URL resolves — actually fetch it, and confirm what came back is the document and not an error page or a login wall. Confirm the current API version. Where the canonical URL cannot be fetched from this environment, the Notes row still names it — it is where the document lives — followed by what was read instead and why, so that a later reader knows which claims rest on a mirror rather than on the provider. A bare authoritative URL in the Notes table asserts that the document behind it was read. Never write a source you did not fetch, and never fall back to memorized API knowledge when the source is unreachable: a mirror, the provider's shipped source, or an official client library is evidence; recall is not. 2. Choose the operations. The plugin is how a caller reaches this API from any SDK, so the surface to expose is the one a caller would actually use — the provider's primary resource and what is done to it — not the smallest set that happens to surround a single job. - **Actions** — the calls that do something a caller would make and want the outcome of: the writes on the primary resource, and any durable, named unit the provider offers (a run, a job, an export). Where such a unit exists it is the centre of the plugin; an inline variant of the same machinery (an ad-hoc command, a raw script run) is the same action with its definition inlined — exclude it unless the provider has no named unit. Where the provider has no long-running unit, its ordinary writes are the actions, specified `request_response`. Never hunt for something slow to justify a plugin, and never inflate a read into an action to have one. - **Collapse the provider's async split.** Many providers cut one piece of work in two: `POST /images` answers at once with a pending record, and the caller polls `GET /images/{uid}` until it is done. That split is a workaround for HTTP, which cannot hold a request open for minutes. A durable promise can. So the plugin does not reproduce it — `image.create` submits *and* polls to a terminal state and resolves with the finished image. One call, one await, the result in hand; the caller never learns the provider made them ask twice. This is the default and it is the point of the plugin, so specify the waiting operation first and always. The poll endpoint is not an operation of its own — it is the mechanics of `image.create`. The record read stays legitimate as a plain read (`image.get`) and carries the disclaimer that says so. Offer the non-waiting variant only where a caller would want it, and name it `<resource>.submit`: a reserved verb meaning exactly this everywhere — hand the work to the provider and resolve as soon as it is accepted, carrying the handle the caller needs to come back to it. Its Resolved schema is the handle, not the result, and its Param description says in as many words that resolution means accepted, not finished. The pair shares everything but the wait: the same Integration Request, the same Param schema, the same Invocation rung and injected identity, differing only in Monitoring (`request_poll` against `request_response`) and in what Resolved carries. `submit` earns its place when the wait is long enough that a caller might genuinely not want to hold a promise open for it, and when the provider's handle stays good long enough to be redeemed later. Where the handle expires, or the status is readable only for a moment, `submit` hands back something that cannot be cashed in and should not exist. Where the provider already answers with the finished result in one call there is nothing to collapse and nothing to offer: one operation, `request_response`. Never specify `submit` without its waiting counterpart — that is the provider's shape, not ours. - **Reads** — enumerating and fetching what the actions name or produce: the list a caller pages to find an id, the get whose shape constrains an action's args, the read of an action's own record, and where results are published separately, the read that retrieves them. - **Bounds** — leave out what a caller would not reach for through a durable promise. Instance administration is out: users, permissions, licences, settings, and schema or definition management the provider expects to happen out of band. Transport mechanics are out: a poll endpoint, a token exchange, a webhook registration. But an update or a delete on the primary resource is an ordinary action needing no special argument — only an update or delete on the provider's own configuration is administration. Size the set by the resource, not by a number. Name the primary resource, then support its whole lifecycle — every verb the provider offers on it, with batch and scoped variants belonging to the verb they implement rather than counting as extra. A verb the provider supports and the plugin omits needs a reason stated in the Param description of the nearest operation, not silence: a caller who can create a record but not read, update or delete it has to abandon the plugin the moment they need the other half of the API, which is worse than never having had it. A provider with one durable unit and the reads that feed it lands around four operations; one with a full CRUD resource lands at two or three times that, and should. What keeps the set honest is not a ceiling but the question behind every entry: would a caller driving this provider from an SDK reach for it? An operation that fails that question is surface to maintain and to get wrong, however cheap it looks to specify. Where an action polls, the Param description of each read that could be mistaken for its completion mechanism states "A plain read — not the completion mechanism; `<action op>` observes independently." Where no action polls there is nothing to disclaim and the sentence is omitted. Names are `resource.verb`, both segments lowercase with no separators (`dagrun`, not `dag_run`). Verbs: `create`, `get`, `list`, `update`, `delete`; use the provider's own verb only when none of those is truthful (`dagrun.trigger`). When one resource has two reads — its record and a separately published output — the output read is its own compound resource (`executionoutput.get`). The Python function name is the func with `.` → `_`. 3. Fill the template below, one subsection at a time, per the rules in the next section. 4. Validate. Run `python3 skills/plugin-specify/lint.py plugins/<name>/spec/specification.md`; it must pass clean. Then confirm by hand what the lint cannot: every endpoint, field name, status code, enum, and default against the fetched documentation. Where the documentation is silent on a status, read-only probes of the §5 instance — and requests the provider rejects without effect (a run against a nonexistent id) — are admissible evidence; record what proved each claim. 5. Test, only if the provider can run locally end to end: run the §5 blocks and confirm the image builds, the provider comes up serviceable, and every configuration value and environment variable the implementations need is exported. Operations are not executed at specification time; that is the implementation's test. 6. Leave `Reviewed by` empty. Review is a separate step by a separate agent following `skills/plugin-review`. ## Template ~~~markdown # <Provider> | | | |---|---| | **API** | `<base URL>` | | **Idempotency** | <mechanism + window, or "No idempotency"> | | **Reviewed by** | | **Notes** | | | |---|---| | **OpenAPI** | `<spec URL>` | | **Self-hosted** | <yes — §5 | no — SaaS only, no §5> | ## 1. Address ``` <scheme>://[{instance}] # omitted instance = "default" ``` ## 2. Configuration ```toml [<scheme>.{instance}] # [<scheme>] = [<scheme>.default] ``` | key | type | default | example | |---|---|---|---| | `<key>` | `<Rust type>` | <default> | `<example>` | ## 3. Authentication & Authorization | | | |---|---| | **Documentation** | [<title>](<provider auth doc URL>) | | **Probe** | `<read-only request, full path from host root>` → `200` | ``` <the auth header, e.g. Authorization: Bearer {api_key}> ``` ## 4. Operations ### 4.1 <resource.verb> | | | |---|---| | **Documentation** | [<title>](<this operation's provider doc URL>) | ```json { "func": "<resource.verb>", "args": { ... } } ``` ### 4.1.1 Promise Param Schema ```json { "description": "...", "type": "object", "properties": { ... }, "required": [ ... ] } ``` ### 4.1.2 Promise Value Schema #### Resolved ```json { "type": "object", "properties": { ... } } ``` #### Rejected ```json { "type": "object", "properties": { "code": { "type": "string", "enum": [ ... ] }, "detail": { "description": "..." } }, "required": ["code"] } ``` ### 4.1.3 Integration Request ``` <METHOD> <full path from host root> → <success status> <operation-added header>: <value> ``` ```json { "type": "object", "properties": { ... }, "required": [ ... ] } ``` ### 4.1.4 Integration Response ```json { "type": "object", "properties": { ... } } ``` ### 4.1.5 Implementation | | | |---|---| | **Algebra** | `call` \| `call + poll` | | **Invocation** | `read` \| `create_idempotent` \| `fetch_then_create` \| `create` | | **Monitoring** | `request_response` \| `request_poll` | ```python def <resource_verb>(cfg, promise): ... ``` ## 5. Test ### 5.1 Base Image ```dockerfile <test environment> ``` ### 5.2 Run ```sh <build and run; wait until serviceable; export configuration> ``` ~~~ ## Rules per section **Header.** Table 1: API base URL; the idempotency mechanism with its exact window, or "No idempotency"; `Reviewed by` empty. The Idempotency row also documents the provider's constraints on the injected identity value
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub