---
name: build-connector
description: |
  Phase 2 of 3. Project the research report faithfully into connector YAML.

  **Triggers:**
  - "build [provider] connector"
  - "create YAML config for [provider]"
  - Phase 2 of the connector flow, after research-provider

  **What it does:**
  1. Reads the research artifact and the resolved scope
  2. Delegates YAML authoring to the build skills that own each part of the schema
  3. Runs `stackone validate`, then the build gate, and repairs what it reports

  **Key feature:** Uses partials approach for better organization and maintainability.
sdkVersion: 3.18.1
features:
  stepFunctions: [ browser_use, call_action, code_execution_lambda, code_execution_lambda, download_file, emit_event, mcp_request, paginated_request, request, sign_jwt, soap_paginated_request, soap_request, static_values, upload_binary, upload_file ]
  expressionFunctions: [ asString, capitalize, decodeBase64, decodeUri, dedupe, deltaFromNowMs, encodeBase64, encodeUri, formatDate, groupBy, hasPassed, hmacSha256, includes, includesSome, isoToUnix, join, keys, lower, md5, min, missing, nextAnniversary, now, padStart, parseJson, pluck, pluckNested, present, reduce, regexMatch, replace, sha256, slice, split, truncate, unixToIso, upper, urlParam, values, webhookEventsUrl, wrapXmlArray, yearsElapsed, zipObject ]
  inputTypes: [ boolean, datetime_string, enum, number, object, string ]
  authTypes: [ custom, oauth2 ]
  paginationModes: [ cursor ]
  validationRules: []
---

# Build Connector

Phase 2 of **Research → Build → Validate**.

## Where everything lives

Read this instead of discovering it with `ls`. One observed run spent a third of its
wall-clock re-deriving this layout by hand.

```
.connector-dev/{{provider}}/          # ALL state and evidence — never the connector YAML
├── run.json                          # phases, scope, gap ledger  (only scripts/state/run.js writes it)
├── build-state.yaml                  # connector_path lives here — read it, do not guess
├── research/{{provider}}_research.json
├── build/auth-evidence.json          # required by A10
└── validation/{{provider}}_<resource>_doc_comparison.json

<connector_path>/                     # ONLY connector YAML — from build-state.yaml
├── {{provider}}.connector.s1.yaml
└── {{provider}}.<resource>.s1.partial.yaml
```

Re-run any gate directly — each prints failing checks plus a `fixes` list saying what to
change. Read that; do not open the gate's source:

```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/phase.js --phase build \
  --provider {{provider}} --connector-path <dir> --cli-ok <true|false>
node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/<auth|build|conventions>.js \
  --provider {{provider}} --connector-path <dir> [--only A9,A10]
node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/<gate>.js --schema   # what the gate reads and checks
```

**A full repair round is ONE `phase.js` call, not three gate calls.** It runs
auth→build→conventions in a single invocation and returns one combined report; three
separate Bash turns pay two extra round-trips per round for the same information. Reach
for an individual gate only with `--only`, to recheck the specific checks you just fixed.

## Inputs

| What | Where |
|---|---|
| Research artifact | `.connector-dev/{{provider}}/research/{{provider}}_research.json` |
| Resolved scope | `.connector-dev/{{provider}}/run.json` → `scope` |

Build only what scope selected. Endpoints in `scope.dropped[]` are deliberate exclusions —
do not build them, do not remove them from the record.

## Where the schema is defined

**Load these rather than writing YAML from memory.** Each owns one part of the schema and is
the only correct reference for it:

| Load | For |
|---|---|
| `references/build-connector-config.md` | main `{{provider}}.connector.s1.yaml` — info, baseUrl, `$ref` entries |
| `references/auth-oauth2.md` · `auth-oauth2-client-credentials` · `auth-oauth2-code-exchange` · `auth-oauth2-code-longterm` · `auth-oauth2-code-refresh` · `auth-custom` · `auth-soap` | the authentication block, one per mechanism research found |
| `references/config-ratelimit.md` | `rateLimit` |
| `references/build-actions.md` | adding all actions for a resource to its partial |
| `references/action-config.md` | the action object — actionId, label, description, details |
| `references/input-config.md` | **`inputs` — the full input schema** |
| `references/args-config.md` | `args` — mapping inputs into the request |
| `references/step-function-request.md` | `request` / `paginated_request` steps |
| `references/step-function-upload-download.md` | file upload and download steps |
| `connector-patterns` | **evidence-based reference from 481 shipped connectors** — every input type, arrays, nesting, enums, variants, rules, conditions, pagination, result shapes |

> **Filenames use lowercase letters, digits and dots. Nothing else — ever.**
>
> No hyphens, no underscores, no spaces, no commas, no capitals. A multi-word provider runs
> together as one token, and the folder matches it exactly:
>
> | Never | Always |
> |---|---|
> | `azure-blob-storage.connector.s1.yaml` | `azureblobstorage.connector.s1.yaml` |
> | `azure_blob_storage.blobs.s1.partial.yaml` | `azureblobstorage.blobs.s1.partial.yaml` |
> | `src/configs/google-sheets/` | `src/configs/googlesheets/` |
>
> The filename is **parsed**, not decorative: a partial's resource is read out of its name —
> provider prefix dropped, remaining dot-segments joined — and must match a `$ref` in the
> main file. A hyphen produces a resource name nothing refers to, which fails as a blocking
> B8 or B2 that never mentions the filename as the cause. A space or comma also breaks the
> first shell command that interpolates the path unquoted.
>
> Dots separate provider from resource, never words within either.
>
> **The resource segment is research's resource key, copied verbatim.** Research named it
> (`container`, `blob`); the partial is `{{provider}}.container.s1.partial.yaml` — never a
> re-pluralized `containers`. B2 matches partials to researched resources by that string,
> and a measured azureblobstorage run renamed both partials after the gate refused the
> plural forms it had invented.

> **Load `connector-patterns` before writing any YAML — not when you get stuck.**
> It is not Skill-invocable (`disable-model-invocation`) — a `Skill` call will refuse.
> Read its files directly:
> `${CLAUDE_PLUGIN_ROOT}/skills/connector-patterns/SKILL.md` and
> `${CLAUDE_PLUGIN_ROOT}/skills/connector-patterns/references/<file>.md`.
>
> Not "if the shape is unclear". You cannot tell which shapes you have wrong, because the
> wrong ones parse: `- key:` on an input, `in: header`, a bare `values:` enum and an
> unquoted `{{…}}` all produce a file that reads correctly and does nothing at runtime.
> Every one of those looks obvious while being wrong.
>
> Loop 1 opens with `references/authentication.md`. Loop 2 opens with `SKILL.md`. Both are
> mandatory reads for the phase, not lookups for when something fails.
>
> **Read every reference file a loop needs in ONE message** (parallel Read calls) — none
> depends on another, and a measured run read five of them one-per-turn, paying a full
> context replay per file for no ordering benefit.

## Rules that are never violated — N1 to N23

`connector-patterns/references/naming-conventions.md` is a **pre-write read and a post-write
check**, in both loops. Mined from all 484 shipped connectors; each rule carries its
adherence, so none of it is preference.

| | Rule | Corpus |
|---|---|---|
| `N1` | `actionId` is snake_case `^[a-z][a-z0-9_]*$` | 30,713/30,726 |
| `N2` | Input `name` carries **no bracket or type symbol** | 498 violations |
| `N3` | Every input states `required` explicitly | 100% |
| `N4` | Input `type` ∈ string · number · object · boolean · enum · datetime_string | only 6 exist |
| `N5` | An arg fed by an **optional** input carries a `condition` | 99.7% |
| `N6` | Description present, starting with a capital | 100% |
| `N7` | Static `baseUrl` never names sandbox/demo/staging | **0 of 484** |
| `N8` | One environment, `production`, unless sandbox is in scope | 92.1% |
| `N9` | `label` is Title Case, no trailing period | 94.4% |
| `N10` | Sandbox is one conditional connector, never two | 29 connectors |
| `N11` | An arg fed by a **required** input carries no `condition` | — |
| `N12` | Input description ≥ 10 characters | 99.1% |
| `N13` | `info.version` is semver (new connector → `0.1.0`) | — |
| `N14` | Every scope an action requires exists in `scopeDefinitions` | — |
| `N15` | `${credentials.X}` in `baseUrl` → declared field, `required: true` | — |
| `N16` | Every non-event action declares `effects` (read·search·write·delete·execute) | 271/271 |
| `N17` | `authRequirements` keyed by auth block id — never `requiredScopes` | — |
| `N18` | Environments are deployment stages only (production·sandbox·staging) | 18/18 |
| `N19` | Every action cites a `resources` documentation URL | 271/271 |
| `N20` | Exactly one step per action | 271/271 |
| `N21` | Step `url` is a **pure path** — every query param (literals too) is an arg `in: query` | 98.75% |
| `N22` | No empty-bracket `[]` suffix on arg names (`range[min]` keyed form is fine) | 125,057 vs 245 |
| `N23` | `info.changelog` on **every** generated connector — 1-2 line customer summary; optional labels ∈ auth·downstream-change·bug-fix·new-actions | mandatory |

Two of these carry the most weight:

- **N2** — brackets are the provider's *wire* syntax and belong in the **arg** name, never
  the input. `start_gte` → `start[gte]`. This is why arg names legitimately differ from the
  input feeding them.
- **N7/N8/N10** — **production only.** Sandbox is extra surface the customer did not ask
  for. If they did ask, set `scope.sandbox: true` in `run.json` and serve both from one
  conditional baseUrl. Never a second connector.

Never invent a rule these do not state. A closed verb vocabulary, `list_` → plural, and
"descriptions end with a period" were all measured and **rejected** — see the last section of
`naming-conventions.md` before adding a convention of your own.

> **Never build a unified action.** No `schemaType: unified`, `fieldConfigs`, `schema`,
> `compositeIdentifiers`, `cursor`, `entrypointUrl` or `entrypointHttpMethod`, and no
> `unified_` actionId prefix. Build non-unified actions that mirror the provider's API and
> return its response unchanged. See `connector-patterns` for how to recognise one.

> **Inputs are keyed by `name`, not `key`.** `input-config` is authoritative. Writing
> `- key:` produces YAML the schema rejects, and it is the single most common way this phase
> fails — check it first when `stackone validate` complains about inputs.

> **Write each resource partial complete, in one `Write`.** All its actions, all their
> inputs, all their args — then run the gate. Do not create a partial and grow it action by
> action, and do not patch one gate finding per `Edit`.
>
> Measured across real runs, this is a 6× spread: one produced 23 actions in 12 file
> operations, another took 23 operations to produce 7. Every extra operation is a turn, and
> turns are the budget that decides whether validate is reached at all.

**Run auto-fix before each gate round.** The mechanical violations — a lowercase label, a
description missing its capital, an input with no `required` line, a non-preview
`releaseStage`, an unquoted numeric-looking `placeholder`, a quoted `$ref` value (the
`stackone` CLI's loader cannot strip the quotes and fails with ENOENT), a sole auth block
whose map key does not match the sole researched mechanism type — have exactly one correct
output, so a model does not need to be involved:

```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/autofix.js \
  --provider {{provider}} --connector-path <dir>
```

It edits line-by-line, so comments and key order survive, and logs every change to
`.connector-dev/{{provider}}/build/fix-log.json` — `--revert` undoes the run. It never
touches anything needing judgement: no renames (that would break the `$ref` declaring the
partial) and no invented descriptions.

Each fix it applies is a repair round that never happens, and repair rounds are the budget
that decides whether validate is reached at all.

## The repair round

A round is: read **every** failing check the gate reported, fix **all** of them, re-run.
One fix per round is what turns a two-round repair into fifteen.

```bash
# the whole phase in one call — auth, build and conventions, failures only
node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/phase.js --phase build \
  --provider {{provider}} --connector-path <dir> --cli-ok <true|false>

# between rounds, re-check only what you touched
node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/build.js --provider {{provider}} --only B4,B7
```

**A scoped run is not a verdict.** `--only` answers "did my fix land?" and reports
`verdict: scoped-pass`. The phase advances **only** on a full run exiting 0 — a green subset
while other checks fail is how a phase gets marked complete over live blocking gaps.

Re-run `stackone validate` only after the YAML changed.

Every full run reports its `round`, and `noProgressRounds` once rounds stop resolving
anything. At `stalled: true`, mark the phase `exhausted` with what remains — the gate prints
the command. A loop that will not converge is not a reason to keep spinning.

## Two loops, in order

Build runs **auth to green first, then actions**. They are separate loops because they fail
differently: a wrong parameter breaks one action, a wrong auth block breaks every action at
once and reports it as an unexplained 401. Auth also parses and validates perfectly while
being completely wrong, so nothing downstream will catch it for you.

Do not start writing actions until `auth.js` exits 0.

### Loop 1 — auth

**Read first, before writing the auth block:**

- `connector-patterns/references/authentication.md` — all 20 `authorization` shapes, the
  `setupFields`/`configFields` schema, derived credentials via `targetFieldKey`, both
  `testActions` forms, `environments`, `refreshAuthentication`
- `connector-patterns/references/naming-conventions.md` § N7 N8 N10 — **production only**;
  sandbox is not built unless `scope.sandbox` says the customer asked for it
- the provider's own auth documentation — and record it in `auth-evidence.json` as you go

**On any `stackone validate` error that contradicts those references, the CLI wins.** Fix
to the error message and move on — never reverse-engineer the schema from `node_modules`
bundles; that archaeology was the single biggest time sink of a measured run.

**`testActions.action` must be an id from `research.json → endpoints[].id`, copied
verbatim.** The auth block is written before any action partial exists, so this reference
points forward — and a guessed name is the measured A8 failure (`list_agents` guessed,
`agents_list` built; one repair round). The ids are already decided: research fixed them.
Never invent one.

**`run.json → scope.auth` decides which mechanisms to build — not research, and not the
prompt you remember.** `auth.resolved` is the build list; `auth.dropped` is deliberately
left out. When `auth.mode` is `all`, build every mechanism research found.

Read it before you start:

```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/state/run.js read --provider {{provider}} | jq '.scope.auth, .scope.sandbox'
```

A9 fails in **both** directions — a missing in-scope mechanism and an unrequested one. That
is deliberate: building auth the customer did not ask for is scope creep, not generosity.

```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/auth.js \
  --provider {{provider}} --connector-path <dir>

# post-write: the environment and baseUrl rules
node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/conventions.js \
  --provider {{provider}} --connector-path <dir> --only N7,N8,N10
```

| Check | Requirement |
|---|---|
| `A1` | Connector loads and declares at least one auth block |
| `A2` | Every block has `type`, `label`, `authorization`, `environments` |
| `A3` | `authorization` is complete for its type — bearer needs `token`, oauth2 needs `tokenUrl` + `token` |
| `A4` | `setupFields`/`configFields` well-formed — keyed by **`key`**, with `label`, `type`, `required` |
| `A5` | **Every referenced credential resolves** to a declared field, a platform credential, or a `targetFieldKey` |
| `A6` | OAuth `redirect_uri` is built from `${apiHostUri}` |
| `A7` | Mechanisms research marked refreshable declare `refreshAuthentication` |
| `A8` | `testActions` resolve and are read-only |
| `A9` | **Exactly** the in-scope mechanisms — no fewer, no more |
| `A10` | Provider auth documentation was read — `build/auth-evidence.json` |
| `A11` | `support` description, and a guide for each declared field set |
| `A12` | Every declared field is **referenced** somewhere in the connector |

Four of these catch errors that survive every other check:

- **A5** — the deepest one. A credential nothing declares is silently empty forever, and both
  spellings look right side by side. A shipped connector persists its refresh token from
  `$.credentials.refresh_token` while the credential is `refreshToken`, so its connections die
  at the first token expiry. `stackone validate` cannot see this.
- **A6** — a hardcoded `redirect_uri` works in exactly the environment it names. One shipped
  connector has a developer's ngrok tunnel here.
- **A4** — credential fields use `key`; action inputs use `name`. A field keyed `name` never
  appears on the connection form, so the user is never asked for it.
- **A12** — the mirror of A5. A field nothing references asks the user for a secret the
  connector never sends, which reads to them as the connector being broken.

**A10 needs `auth-evidence.json`**, written as you read the provider's auth documentation:

```json
{
  "docsRead": ["https://developer.example.com/docs/authentication"],
  "mechanisms": [
    {
      "type": "oauth2_authorization_code",
      "docsRead": ["https://developer.example.com/docs/oauth"],
      "confirmed": ["authorizationUrl", "tokenUrl", "scopes", "refresh"]
    }
  ]
}
```

This is the one thing in this phase that must come from the provider rather than from
research: research records *which* mechanisms exist, not the URLs, scope strings, refresh
semantics and field names needed to configure them. Cite what you read, per mechanism.

### Loop 2 — actions

**Read first, before writing any partial:**

- `connector-patterns/SKILL.md` — inputs and every type, arrays, nesting, enums, `args`,
  conditions, URL interpolation, pagination, result shapes, the action envelope
- `connector-patterns/references/naming-conventions.md` — N1–N22, which govern
  every actionId, label, description, input, step url and arg you are about to write
- `connector-patterns/references/tricky-handling.md` for file chains, SOAP, GraphQL and event actions;
  `connector-patterns/references/schema-vs-corpus.md` before trusting anything the published docs say

**Input names are the provider's exact wire names.** B4 and B5 match each researched
parameter to an input **by literal name**: a header documented as `x-ms-lease-id` is an
input named `x-ms-lease-id`, never `leaseId` or `lease_id`. Do not sanitise, camelCase, or
snake_case a provider name — the one exception is N2's bracket rule, where the wire name
`start[gte]` becomes input `start_gte` mapped to an arg named `start[gte]`. A measured
azureblobstorage run guessed sanitised names first and rewrote both 900-line partials.

**Pilot one action through the gates before writing the rest — and pilot the HARDEST
one.** Pick the action with the most unusual shape in scope (an XML or binary body, a
file upload/download, an unusual method, the heaviest header set), not a plain list — a
plain list validates while every open design question waits in the batch. After that
first action, run:

```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/build.js \
  --provider {{provider}} --connector-path <dir> --only B4,B5,B10
node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/conventions.js \
  --provider {{provider}} --connector-path <dir>
```

Ignore coverage failures (B2/B3 — most endpoints are deliberately unwritten); read only
what fires on the action you wrote. A shape mistake caught here is one 30-line fix; the
same mistake discovered after all partials exist is a full rewrite of every action — the
measured azureblobstorage run paid that twice (B4 literal names, N21 query strings) on
2,000 lines of YAML.

**After the pilot passes, write independent partials in one message.** Each resource's
partial depends on nothing in the others — issue the Write calls together (2–3 per
message), the way the reference reads are already batched. One partial per turn pays a
full round-trip per file for no ordering benefit.

```bash
stackone validate <connector_path>

node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/build.js \
  --provider {{provider}} --connector-path <dir> --cli-ok <true|false>

# post-write: naming and content rules across the whole connector
node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/conventions.js \
  --provider {{provider}} --connector-path <dir>
```

The CLI's whole surface here is that one line: `stackone validate <path>` — path is the
connector file or directory; exit 0 is the pass `--cli-ok true` reports. Nothing else to
learn.

**If `stackone` is missing or crashes on launch** (a broken global install is a measured,
recurring event — e.g. a corrupted nested module inside the global npm tree), do not
diagnose the global install. Fall back in one step: install a clean copy somewhere
writable and use its own bin link —

```bash
# .connector-dev/ is always writable in a run — /tmp is NOT (sandboxed sessions EPERM it, measured)
npm install --prefix .connector-dev/.cli --cache .connector-dev/.npm-cache @stackone/cli
.connector-dev/.cli/node_modules/.bin/stackone validate <connector_path>
# equivalent direct entry point: node .../node_modules/@stackone/cli/dist/cli.mjs
# (verified against @stackone/cli package.json: "bin": {"stackone": "dist/cli.mjs"})
```

One fallback attempt, then continue the loop. Tooling archaeology is not connector work.

| Check | Requirement |
|---|---|
| `B1` | `stackone validate` exits 0 |
| `B2` | Every in-scope resource has a partial file |
| `B3` | **Every in-scope endpoint has an action** — built ÷ discovered-in-scope = 100% |
| `B4` | Every documented parameter is exposed on its action — matched by **exact name** |
| `B5` | Parameter fidelity — `required`, `in`, and **complete** enum values match research |
| `B6` | Every non-deprecated auth mechanism is configured |
| `B7` | Every action and parameter has a substantive description |
| `B8` | Wiring — `$ref`, input→args, step function present |
| `B9` | No duplicate `actionId`s |
| `B10` | Input structure — `name` not `key`, `in: headers` not `header`, enums as `oneOf.values` |
| `B11` | Expressions — JEXL single-quoted, no logic in a bare JSONPath |
| `B12` | No unified actions |
| `B13` | `actionType` is `custom` (or `event`) |

`--cli-ok` carries the `stackone validate` verdict. B1 fails when it is omitted — an unrun
check must never read as a passing one.

Four fail silently if you are not watching for them:

- **B3** — one action per researched endpoint. Not "the important ones".
- **B5** — a partial enum is worse than none, because it looks complete. Five documented
  values means five in `oneOf.values`.
- **B7** — a description that restates the action id is treated as absent, because to an
  agent calling this connector it is.
- **B8** — an input that never appears in a step's `args` or the request URL is dead: it
  validates and silently does nothing.

B10–B13 encode the corpus rules from `connector-patterns`. They exist because these shapes
parse — the file is valid YAML and often passes `stackone validate` — while doing nothing at
runtime.

### When a check fails, read this — do not guess

Every gate failure has a documented answer. Go to it rather than trying a different shape:

| Check | Read |
|---|---|
| `A3` `A6` `A7` | `references/authentication.md` — the 20 authorization shapes and OAuth variants |
| `A4` `A5` `A12` | `references/authentication.md` § setupFields/configFields, § derived credentials |
| `A8` | `references/authentication.md` § testActions — both forms |
| `A11` | `references/authentication.md` § support |
| `B4` `B5` `B10` | `SKILL.md` § inputs — types, arrays, enums (`oneOf.values`), `in` locations |
| `B8` | `SKILL.md` § args — mapping inputs into the request |
| `B11` | `references/schema-vs-corpus.md` § the three expression formats |
| `B13` | `SKILL.md` § the action envelope |
| `N1`–`N6`, `N9`, `N11`, `N12` | `references/naming-conventions.md` — the rule that fired, by number |
| `N7` `N8` `N10` | `references/naming-conventions.md` § N7–N10 — production only |
| pagination, files, SOAP, GraphQL, events | `references/tricky-handling.md` |
| "the docs say something different" | `references/schema-vs-corpus.md` — the corpus wins |

Repeating a fix the gate already rejected, or trying a shape no reference documents, is how
a loop burns rounds without converging. If the reference does not cover your case, say so in
the gap rather than inventing a shape.

Reach a validating config before perfecting coverage — a connector that does not validate is
worth nothing, so B1 comes first even though B3 matters most.

Rerun scoped to what changed:

```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/build.js --provider {{provider}} --scope candidates
```

### Leaving the phase

**All three gates must exit 0** — `auth.js`, `build.js`, `conventions.js`. Two consecutive
rounds resolving nothing → mark the phase `exhausted`, record what remains, move on.

```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/state/run.js set-phase \
  --provider {{provider}} --phase build --status <complete|exhausted> [--unmet B3,B7]
```

Never report this phase complete from what the console printed. Read the run state.

## Do not verify coverage against the documentation here

Re-deriving the action surface from the provider's docs is the next phase's job, and it is
deliberately done by someone who did not build this. Checking your own build against the
research you built it from proves nothing.

Auth is the exception, and only auth: A10 requires the provider's auth documentation because
no research artifact specifies an auth block completely, and an unverified one invalidates
every action behind it.

## Configuration Standards

### Required Fields (Every Action)

```yaml
- actionId: unique_operation_name      # Required
  effects: [read]                      # Required for tool actions; omit for auth/event (see Action Effects)
  label: Human Readable Name           # Required
  description: Short description       # Required (1-2 sentences, shown in UI)
  details: Detailed description        # Required (longer, used by AI agents)
  resources: https://specific.url      # Required (action-specific doc link)
  inputs: [...]                        # Optional (empty array if no inputs)
  steps: [...]                         # Required (at least one step)
  result: {...}                        # Required (what operation returns)
```

### Action Effects (`effects`) — REQUIRED

`effects` is an array declaring what the action does — one entry per distinct effect across its steps.
It powers the MCP tool annotations (`readOnlyHint`, `destructiveHint`, …) and dashboard action
filtering, so set it deliberately on every tool action (authentication/token and inbound `event`/webhook
actions are the exception — they carry no `effects`; see Rules).

**Values:**
- `read` — retrieves data without changing it (list, get, retrieve, fetch, count, export)
- `search` — a search or query operation (kept distinct from `read`)
- `write` — creates or modifies a resource (create, update, upsert, add, import, send)
- `delete` — removes a resource (delete, remove, revoke, purge, archive)
- `execute` — an imperative / RPC operation that is not CRUD (run, deploy, trigger, approve, cancel, enable/disable, rotate, refresh)

**Rules:**
- **Derive from the action's semantic intent, NOT the HTTP method.** A GraphQL list is `method: post`
  but its effect is `[read]`; a `run_query` is `[execute]`.
- **List every distinct effect.** A find-or-create that reads then writes is `[read, write]`.
- **Never label a mutating action read-only.** An action is read-only only when *every* effect is
  `read` or `search`; anything that creates, modifies, deletes or executes MUST include the matching
  mutating effect. Under-declaring drops the MCP approval gate — it is a safety bug.
- **No `effects`** on authentication/token actions or inbound `event`/webhook actions.

**Escape hatch:** when the hints derived from `effects` cannot express the action (e.g. a genuinely
idempotent POST), set explicit MCP `annotations` on the action — any field there overrides the
derived hint:

```yaml
  effects: [write]        # still required — annotations only override the derived hints
  annotations:
    readOnlyHint: false
    idempotentHint: true
```

**Examples:**

```yaml
- actionId: list_users         # effects: [read]
- actionId: search_candidates  # effects: [search]
- actionId: create_employee    # effects: [write]
- actionId: update_employee    # effects: [write]
- actionId: delete_employee    # effects: [delete]
- actionId: run_report         # effects: [execute]
```

### Input Location (`in`) Rules

- **Every input MUST include an `in` field.**
- **Valid values are only:** `body`, `query`, `path`, `headers`.

### Input Types

```yaml
- # Valid input types: string, number, boolean, enum, object, datetime_string

- name: status                # enum example
  label: Status Filter
  type: enum
  in: query
  oneOf:
    values:
      - active
      - inactive
      - pending
  required: false
  description: Filter users by status

- name: ids                   # array example (NEVER use type: array)
  type: string
  array: true
  in: query

- name: metadata              # object example
  type: object
  in: body
  properties:
    - name: key1
      type: string
    - name: key2
      type: number
```

### Step Function Patterns

**`method` is one of `get post put patch delete` — nothing else.** All 29,789 corpus steps
use those five; the CLI rejects `head` (measured — one run probed the CLI mid-build to
discover it). A HEAD-documented metadata endpoint is built as `get` on the same URL when
the provider serves it that way, otherwise recorded in `dropped[]` with a reason.

**Request (simple API call):**
```yaml
stepFunction:
  functionName: request
  parameters:
    url: /resource
    method: get
    args:
      - name: param
        value: $.inputs.param
        in: query
```

**Paginated Request (cursor-based only):**
```yaml
stepFunction:
  functionName: paginated_request
  parameters:
    url: /resource
    method: get
    response:
      dataKey: items
      nextKey: pagination.next
    iterator:
      key: cursor
      in: query
```

**SOAP Request (XML APIs):**
```yaml
stepFunction:
  functionName: soap_request
  parameters:
    url: /soap/endpoint
    method: post
    soapOperation: GetResource
    namespaces:
      - namespaceIdentifier: ns
        namespace: http://example.com/ns
    args:
      - name: ResourceId
        value: ${inputs.id}
        in: body
```

**SOAP Paginated Request (XML APIs with pagination):**
```yaml
stepFunction:
  functionName: soap_paginated_request
  parameters:
    url: /soap/endpoint
    method: post
    soapOperation: GetResources
    namespaces:
      - namespaceIdentifier: ns
        namespace: http://example.com/ns
    response:
      dataKey: items
      nextKey: pagination.nextCursor
    iterator:
      key: cursor
      in: body
```

**Static Values (return constant data without an API call):**
```yaml
stepFunction:
  functionName: static_values
  parameters:
    values:
      - id: active
        label: Active
      - id: inactive
        label: Inactive
```

**Upload File (multipart form upload):**
```yaml
stepFunction:
  functionName: upload_file
  parameters:
    url: /files/upload
    method: post
    args:
      - name: file
        value: $.inputs.file_content
        in: body
      - name: folder_id
        value: $.inputs.folder_id
        in: body
        condition: '{{present(inputs.folder_id)}}'
```

**Upload Binary (raw binary content upload):**
```yaml
stepFunction:
  functionName: upload_binary
  parameters:
    url: /files/${inputs.file_id}/content
    method: put
    args:
      - name: content
        value: $.inputs.binary_data
        in: body
```

**Download File (file download with encoding options):**
```yaml
stepFunction:
  functionName: download_file
  parameters:
    url: /files/${inputs.file_id}/download
    method: get
    mode: buffer
    encoding: base64
```

**Emit Event (webhook/async event emission):**
```yaml
stepFunction:
  functionName: emit_event
  parameters:
    eventType: user.created
    payload: $.steps.create_user.output.data
```

**Sign JWT (sign a JWT token):**
```yaml
stepFunction:
  functionName: sign_jwt
  parameters:
```

**Code Execution Lambda (run custom code in a lambda function):**
```yaml
stepFunction:
  functionName: code_execution_lambda
  parameters:
```

**Browser Use (browser automation):**
```yaml
stepFunction:
  functionName: browser_use
  parameters:
```

### Dynamic Value Formats

**JSONPath (preferred):**
```yaml
value: $.credentials.apiKey           # Credentials
value: $.inputs.userId                # Inputs
value: $.steps.stepId.output.data     # Step output
```

**String Interpolation (URLs):**
```yaml
url: /users/${inputs.id}
url: /users/${inputs.id}/posts/${inputs.postId}
baseUrl: https://${credentials.subdomain}.api.com
```

**JEXL (conditions only):**
```yaml
condition: '{{present(inputs.optional_param)}}'
condition: '{{inputs.include_deleted == true}}'
condition: '{{missing(inputs.override_url)}}'
condition: '{{includes(inputs.roles, "admin")}}'
condition: '{{includesSome(inputs.tags, ["vip", "priority"])}}'
condition: '{{hasPassed(inputs.start_date)}}'
```

**JEXL expression function examples:**
```yaml
# present — true if value exists and is not empty
condition: '{{present(inputs.filter)}}'

# missing — true if value is null/undefined/missing
condition: '{{missing(inputs.override)}}'

# now — current ISO 8601 timestamp
value: '{{now()}}'

# nextAnniversary — next anniversary date from an initial date
value: '{{nextAnniversary(inputs.hire_date)}}'

# yearsElapsed — years between two dates
value: '{{yearsElapsed(inputs.start_date, inputs.end_date)}}'

# hasPassed — true if date has passed (optionally add years)
condition: '{{hasPassed(inputs.expiry_date)}}'
condition: '{{hasPassed(inputs.hire_date, 1)}}'

# deltaFromNowMs — milliseconds between now and target date
value: '{{deltaFromNowMs(inputs.due_date)}}'

# includes — true if array contains ALL given values
condition: '{{includes(inputs.permissions, "read")}}'

# includesSome — true if array contains ANY of the given values
condition: '{{includesSome(inputs.roles, ["admin", "owner"])}}'

# keys — array of object keys
value: '{{keys(steps.fetch.output.data)}}'

# values — array of object values
value: '{{values(steps.fetch.output.data)}}'

# encodeBase64 — encode string to base64
value: '{{encodeBase64(inputs.content)}}'

# decodeBase64 — decode base64 string
value: '{{decodeBase64(inputs.encoded)}}'

# capitalize — capitalize characters in a string
value: '{{capitalize(inputs.name)}}'

# upper — return uppercased form of a string
value: '{{upper(steps.request.output.data.value)}}'

# lower — return lowercased form of a string
value: '{{lower(steps.request.output.data.value)}}'

# decodeUri — decode a URI-encoded string
value: '{{decodeUri(inputs.encoded_url)}}'

# dedupe — canonical string key for deduplication
value: '{{dedupe(inputs.record)}}'

# groupBy — group array of objects by a key
value: '{{groupBy(steps.fetch.output.data, "department")}}'

# join — join array elements into a string
value: '{{join(inputs.tags, ", ")}}'

# min — smallest value from provided numbers
value: '{{min(inputs.a, inputs.b)}}'

# padStart — pad start of string to target length
value: '{{padStart(inputs.id, 8, "0")}}'

# zipObject — combine two arrays into an object
value: '{{zipObject(inputs.keys, inputs.values)}}'

# reduce — aggregate operation over an array
value: '{{reduce(steps.fetch.output.data, "sum", "amount")}}'

# regexMatch — extract value using regex pattern
value: '{{regexMatch(inputs.text, "\\d+")}}'

# replace — replace occurrences of a string
value: '{{replace(inputs.text, " ", "_")}}'

# truncate — truncate string to max length with optional suffix
value: '{{truncate(inputs.description, 100, "...")}}'

# urlParam — extract query param from URL
value: '{{urlParam(inputs.redirect_url, "code")}}'

# hmacSha256 — compute HMAC-SHA256 of a value using a secret key
value: '{{hmacSha256(steps.request.output.data.value, credentials.signingSecret)}}'

# md5 — compute MD5 hash of a string value
value: '{{md5(steps.request.output.data.value)}}'

# sha256 — compute SHA-256 hash of a string value
value: '{{sha256(steps.request.output.data.value)}}'

# asString — convert a value to its string representation
value: '{{asString(steps.request.output.data.value)}}'

# split — split a string into an array using a separator
value: '{{split(steps.request.output.data.value)}}'

# webhookEventsUrl — build the StackOne events webhook URL for native webhooks
value: '{{webhookEventsUrl(steps.request.output.data.value)}}'

# parseJson — parse a JSON string into its decoded value (object, array, or primitive)
value: '{{parseJson(steps.request.output.data.value)}}'

# unixToIso — convert a Unix epoch timestamp to an ISO-8601 date-time string
value: '{{unixToIso(steps.request.output.data.value)}}'

# isoToUnix — convert a date-time string to a Unix epoch timestamp
value: '{{isoToUnix(steps.request.output.data.value)}}'

# wrapXmlArray — wrap an array for XML serialization with fast-xml-parser
value: '{{wrapXmlArray(steps.request.output.data.value)}}'
```

### Result Patterns

**Read operation:**
```yaml
result:
  data: $.steps.fetch_data.output.data
```

**Write operation:**
```yaml
result:
  message: Resource created successfully
  data:
    id: $.steps.create_resource.output.data.id
    name: $.steps.create_resource.output.data.name
```

## Common Patterns

### Pattern 1: List/Get/Create/Update/Delete (CRUD)

**Complete CRUD example for a resource:**

```yaml
# List (paginated)
- actionId: list_items
  steps:
    - stepFunction:
        functionName: paginated_request
        parameters:
          url: /items
          response:
            dataKey: items
            nextKey: next_cursor

# Get (by ID)
- actionId: get_item
  inputs:
    - name: id
      in: path
  steps:
    - stepFunction:
        functionName: request
        parameters:
          url: /items/${inputs.id}

# Create (with body)
- actionId: create_item
  inputs:
    - name: name
      in: body
  steps:
    - stepFunction:
        functionName: request
        parameters:
          url: /items
          method: post
          args:
            - name: name
              value: $.inputs.name
              in: body

# Update (ID + body)
- actionId: update_item
  inputs:
    - name: id
      in: path
    - name: name
      in: body
  steps:
    - stepFunction:
        functionName: request
        parameters:
          url: /items/${inputs.id}
          method: patch
          args:
            - name: name
              value: $.inputs.name
              in: body

# Delete (by ID)
- actionId: delete_item
  inputs:
    - name: id
      in: path
  steps:
    - stepFunction:
        functionName: request
        parameters:
          url: /items/${inputs.id}
          method: delete
```

### Pattern 2: Multi-Step Operations

**Operation requiring multiple API calls:**

```yaml
- actionId: get_user_with_manager
  description: Get user details including manager information
  inputs:
    - name: user_id
      in: path
  steps:
    # Step 1: Get user
    - stepId: fetch_user
      description: Fetch basic user info
      stepFunction:
        functionName: request
        parameters:
          url: /users/${inputs.user_id}

    # Step 2: Get manager (using manager_id from step 1)
    - stepId: fetch_manager
      description: Fetch manager details
      stepFunction:
        functionName: request
        parameters:
          url: /users/${steps.fetch_user.output.data.manager_id}

  result:
    data:
      user: $.steps.fetch_user.output.data
      manager: $.steps.fetch_manager.output.data
```

### Pattern 3: GraphQL API

**GraphQL query operation:**

```yaml
- actionId: list_repositories
  description: List repositories via GraphQL
  steps:
    - stepFunction:
        functionName: request
        parameters:
          url: /graphql
          method: post
          args:
            - name: query
              value: "query($first: Int!) { viewer { repositories(first: $first) { nodes { id name } } } }"
              in: body

            - name: variables
              value: { first: $.inputs.max_results }
              in: body

  result:
    data: $.steps.query.output.data.viewer.repositories.nodes
```

## Anti-patterns

- ❌ Writing YAML from memory instead of loading the skill that owns that part of the schema
- ❌ `inputs: - key:` — the schema wants `name`
- ❌ A monolithic file instead of main + partials
- ❌ Building only "the important" endpoints — B3 counts all of them
- ❌ Copying a partial enum when research recorded the full list
- ❌ Inputs declared but never wired into a step
- ❌ Declaring the phase done without running the gate
- ❌ **Ending the turn once the build gates pass** — validate is still owed

## Build is step two of three

Run the phase's gates in one call rather than three — auth, build and conventions in order,
reporting only what failed:

```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/gates/phase.js --phase build \
  --provider {{provider}} --connector-path <dir> --cli-ok <true|false>
```

When it exits 0, record the transition and read the reply:

```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/state/run.js set-phase \
  --provider {{provider}} --phase build --status complete
```

If `nextPhase` comes back as `validate`, **load `validate-config` and start it in this same
turn.** A connector that has never been checked against the provider's live documentation is
exactly the state in which hallucinated endpoints ship, so stopping here is worse than it
looks — the work appears finished and is not. The Stop hook will refuse the turn anyway.
