- 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