---
name: connector-patterns
user-invocable: false # Loaded by build skills via "Load skill:", not triggered directly
disable-model-invocation: true
description: |
  Evidence-based reference for Falcon connector YAML, derived from 481 shipped connectors
  (30,784 actions, 214,966 inputs). Load when authoring or repairing a partial and the
  correct shape is not obvious.

  Covers: input schema and every type, arrays, nested objects, enums, variants, validation
  rules, argument mapping, conditions, URL interpolation, pagination, result shapes, step
  functions, the action envelope, the main connector file and auth blocks.

  Six companion references carry the rest:
  - `references/naming-conventions.md` — **rules N1–N13, never violated**: actionId, label,
    description, input names and types, arg conditions, and production-only baseUrl
  - `references/tricky-handling.md` — file upload chains, SOAP envelopes, GraphQL, event
    handlers, response-header access, custom errors, binary responses, a trap table
  - `references/authentication.md` — all 20 authorization shapes, setup/config fields,
    environments, refresh, rate limits, webhook event routing, and the comparison-series
    hardening rules (A13–A16)
  - `references/schema-vs-corpus.md` — where the published docs and shipped connectors
    disagree, the three expression formats and their rules, and the full function list
  - `references/exemplars.md` — which shipped connector to read for each case (GraphQL,
    SOAP, client credentials, conditional baseUrl, pagination, files, webhooks)
  - `references/corpus-stats.json` — the mined frequency data behind the rules
    (regenerate with `scripts/corpus/mine.js`); consensus beats any single exemplar

  Every frequency below is a real count from the corpus. Where one form dominates, follow it —
  the rare forms exist for cases you will recognise when you hit them.
---

# Connector Patterns

Mined from `connectors/src/configs` — 481 providers, 5,921 partials, 30,784 actions,
214,966 inputs, 6,431 distinct key paths. Counts are how often each form actually appears.

Nothing here was chosen in advance: every key path in the corpus was enumerated, so anything
a shipped connector does is covered somewhere. This file holds the shapes you need most days;
**`references/tricky-handling.md`** holds the rest — per-step auth and base URLs, all five
pagination shapes, transform steps, file upload/download chains, SOAP, custom errors, query
encoding, the expression vocabulary, and a symptom→cause trap table.

## The three expression formats

There are three, and mixing them is the single most common authoring error. Full rules and
the function list are in `references/schema-vs-corpus.md`.

| Format | Syntax | Purpose |
|---|---|---|
| **JSONPath** | `$.inputs.x` | direct data access |
| **String interpolation** | `${inputs.x}` | building URLs and strings — no logic possible |
| **JEXL** | `'{{expr}}'` | logic, conditionals, functions — **must be single-quoted** |

Never mix two in one field: `'{{$.inputs.limit ?? 25}}'` is right, `'$.inputs.limit ?? 25'`
is not — JSONPath cannot express logic.

The pair that trips people up most:

| Where | Syntax | Count |
|---|---|---|
| **URL path** | `${inputs.candidateId}` — template interpolation | 18,194 |
| **Arg value** | `$.inputs.candidateId` — JSONPath | 94,579 |

```yaml
url: /candidates/${inputs.candidateId}/notes   # ${...} — braces, no dot
args:
  - name: status
    value: $.inputs.status                     # $. — dot, no braces
```

`$.` in a URL, or `${}` in an arg value, both fail. Other arg value sources:
`$.credentials.X` (7,605), a literal (17,212), `{{expression}}` (3,350), `$.steps.X` (58).

---

## Inputs

Every input carries **`name`**, **`description`**, **`required`** — 214,966 of 214,966. There
are no optional-to-omit fields among those three.

```yaml
inputs:
  - name: status            # NOT `key` — that is auth setupFields, a different schema
    description: Filter candidates by hiring status.
    type: string
    required: false
    in: query
```

| Field | Count | Notes |
|---|---|---|
| `name` `description` `required` | 214,966 each | always present |
| `type` | 214,927 | omitted only when `variants` is used |
| `in` | 131,646 | top-level only; nested `properties` never carry it |
| `properties` | 22,865 | nested object fields |
| `array` | 19,020 | `true` when the value is a list |
| `oneOf` | 14,762 | enum values |
| `rules` | 1,106 | validation |
| `variants` | 39 | polymorphic value |

**Types:** `string` 121,753 · `number` 31,345 · `object` 29,725 · `boolean` 15,519 ·
`enum` 14,701 · `datetime_string` 1,884

**`in` locations:** `body` 66,589 · `query` 42,146 · `path` 22,278 · `headers` 633

---

## Arrays — 19,020 inputs

`array: true` sits *alongside* `type`; it is not a type of its own.

**Array of scalars**
```yaml
- name: tags
  description: Tags to attach.
  type: string
  required: false
  in: body
  array: true
```

**Array of objects** — 9,098 of the 19,020
```yaml
- name: items
  description: Array of vacation entry objects to create.
  type: object
  required: true
  in: body
  array: true
  properties:              # shape of ONE element, not of the array
    - name: user
      description: User id the vacation applies to.
      type: number
      required: true
    - name: start_dt
      description: 'Vacation start date. Format: YYYY-MM-DD.'
      type: string
      required: true
```

`properties` describes a single element. Nested `properties` never carry `in` — only the
top-level input does.

**Array in a query string** — `arrayFormat` on the arg, not the input
```yaml
args:
  - name: ids
    value: $.inputs.ids
    in: query
    arrayFormat: repeat    # ids=1&ids=2  — the only observed value
```

---

## Nested objects — up to 12 levels deep

```yaml
- name: address
  description: Postal address.
  type: object
  required: false
  in: body
  properties:
    - name: line1
      description: Street address.
      type: string
      required: true
    - name: country
      description: ISO 3166-1 alpha-2 country code.
      type: string
      required: false
```

Depth is unlimited in practice — the corpus reaches 12. Nest as deep as the provider's body
requires rather than flattening, because flattening loses the shape the API expects.

---

## Enums — 14,701 inputs

```yaml
- name: order_by
  description: >-
    Ordering field. Prefix with "-" to reverse, e.g. "-create_ts".
    Allowed values: id, create_ts, user, start_dt, end_dt.
  type: enum
  required: false
  in: query
  oneOf:
    values:
      - id
      - create_ts
      - user
      - start_dt
      - end_dt
```

**Carry every documented value.** A partial enum is worse than none, because it looks
complete — and gate B5 fails on any value the docs list that `oneOf.values` omits. Repeating
the values in the description is common and helps agents calling the connector.

---

## Validation rules — 1,106 inputs

```yaml
- name: limit
  description: Maximum number of results per page (integer, 1-50).
  type: number
  in: query
  required: false
  rules:
    min: 1
    max: 50
```

Observed: `maxLength` 532 · `minLength` 243 · `min` 203 · `format` 195 · `pattern` 130 ·
`max` 110 · `errorMessage` 2. Add them when the provider documents a bound — they turn a
provider 400 into a caught error before the request leaves.

---

## Variants — polymorphic inputs, 39 uses

When one field legitimately accepts several types, drop `type` and list `variants`:

```yaml
- name: value
  description: The new value for add/replace operations; omit for remove.
  required: false
  variants:
    - type: string
      description: String value, e.g. for /title or /fields/{fieldId}/label.
    - type: boolean
      description: Boolean value, e.g. for /favorite.
    - type: object
      description: Field object for /fields paths, or a section object for /sections paths.
      properties:
        - name: id
          description: Field or section identifier.
          type: string
          required: false
```

Rare and easy to overuse. Prefer a single type unless the provider genuinely accepts more.

---

## Args and conditions

`name` · `value` · `in` on all 125,302 args. **`condition` on 85,702 — 68%.**

```yaml
args:
  - name: status
    value: $.inputs.status
    in: query
    condition: '{{present(inputs.status)}}'   # 79,286 of 85,702 — 93% of all conditions
```

**Every optional input needs a condition.** Without one the arg is always sent, so an
unset optional becomes an explicit null the provider usually rejects. Required inputs take
no condition.

Other observed forms:

```yaml
condition: '{{present(inputs.a) && present(inputs.b)}}'    # 230 — both required together
condition: '{{present(inputs.a) && !present(inputs.b)}}'   # 182 — mutually exclusive
condition: '{{present(credentials.apiKey)}}'               #   787 — auth-dependent
condition: '{{context.authenticationType == "oauth2"}}'    #   123 — per-auth-type branching
```

**`spread`** — 355 uses. Puts an object or array in as the body rather than under a key:

```yaml
args:
  - name: items
    value: $.inputs.items
    in: body
    spread: true
```

---

## Pagination

**`response.collection: true`** — 691, the most common. The provider returns a bare array
and there is nothing to page through.

```yaml
response:
  collection: true
```

**`response.dataKey`** — 114. Records live under a key.

```yaml
response:
  dataKey: results
```

**Cursor pagination** — 88. `dataKey` + `nextKey`, with an `iterator` naming the request
parameter that carries the cursor. **`nextKey` and `iterator` are only valid on
`functionName: paginated_request`** — the enforced schema is strict: a plain `request`
step's `response` accepts only `collection`/`indexField`/`dataKey`, and `nextKey` or
`iterator` on it fails `stackone validate` with an unhelpful "Unknown field" error. Two
measured runs reverse-engineered the SDK's type definitions to rediscover this rule;
do not be the third.

```yaml
steps:
  - stepId: list_users
    functionName: paginated_request     # REQUIRED for nextKey/iterator
    request: ...
    response:
      dataKey: Resources
      nextKey: '{{$.startIndex + $.itemsPerPage <= $.totalResults ? $.startIndex + $.itemsPerPage : null}}'
    iterator:
      key: startIndex
      in: query
```

`nextKey` may be a plain path (`pagination.next_cursor`) or an expression that computes the
next offset and returns `null` to stop. **Returning null is what ends the loop** — an
expression that never nulls pages forever.

Note `paginated_request` appears only 117 times against `request` 29,328 — but that is
because most list endpoints return one page (`collection`/`dataKey` on plain `request`),
not because `request` can paginate. The decision rule: paging through cursors →
`paginated_request`; single response, no loop → `request`.

---

## Result — 30,784 actions

```yaml
result:
  data: $.steps.fetch.output.data     # 25,532 — the overwhelming default
```

Variants: `$.steps.X.output` 669 · `$.steps.X.output.data.data` 175 ·
`$.steps.X.output.response` 61.

For raw pass-through, `statusCode` + `body` appear 1,613 times each.

---

## Step functions

`request` 29,328 · `emit_event` 1,577 · `map_fields` 757 · `typecast` 423 ·
`static_values` 208 · `soap_request` 136 · `upload_file` 129 · `paginated_request` 117 ·
`download_file` 61 · `upload_binary` 18 · `browser_use` 4

**`request` covers 95% of everything.** Reach for anything else only when it clearly applies.

```yaml
steps:
  - stepId: fetch
    stepFunction:
      functionName: request
      parameters:
        url: /candidates/${inputs.candidateId}
        method: get                     # get 15,242 · post 8,224 · delete 2,854 · put 2,212 · patch 1,257
        args:
          - name: include
            value: $.inputs.include
            in: query
            condition: '{{present(inputs.include)}}'
```

Step parameters by frequency: `url` `method` 29,789 · `args` 25,023 · `event` 1,577 ·
`dataSource` 1,180 · `baseUrl` 1,103 · `response` 1,030 · `authorization` 850 ·
`customErrors` 557 · `fields` 332 · `externalSources` 276 · `values` 208.

**File upload**
```yaml
stepFunction:
  functionName: upload_file
  parameters:
    url: /companies/${inputs.companyId}/requests/${inputs.requestId}/attachments
    method: post
    file:
      fieldName: files
      content: $.inputs.file_content
      filename: $.inputs.file_name
```

**Emit event** — webhook-delivered payloads
```yaml
stepFunction:
  functionName: emit_event
  parameters:
    event:
      eventId: $.inputs.entity_id
      eventType: task.added
      eventDate: $.inputs.timestamp
      data: $.inputs.payload
```

---

## Custom errors — 557 uses

Rewrite a misleading provider response into an accurate one:

```yaml
customErrors:
  - receivedStatus: 200
    targetStatus: 404
    message: Role not found. No permission set matches this id.
```

Use when a provider returns 200 for a not-found, or a 400 that is really a 404. The message
should say what the caller can do about it.

---

## Auth override per step — 850 uses

```yaml
authorization:
  type: none      # this step must NOT carry the connector's auth
```

Needed for token endpoints, pre-signed URLs, and any call the provider expects unauthenticated.

---

---

## Unified actions are out of scope — never build one

683 actions in the corpus are **unified**: they map a provider response into a StackOne
schema (`users`, `roles`, `organizations`, `groups`, `credentials`, …) instead of passing the
provider's own response through. **This flow does not build them.** Build non-unified actions
only — they mirror the provider's API exactly and return its response unchanged.

Recognise a unified action by any of these. If you are about to write one, stop:

```yaml
schemaType: unified          # 662 — the marker
schema: groups               # 640 — StackOne schema name
fieldConfigs: [...]          # 477 — provider field -> unified field mapping
compositeIdentifiers: {...}  # 645
cursor: { enabled, pageSize }# 241
entrypointUrl: /groups       # 681 — unified only
entrypointHttpMethod: get    # 681 — unified only
```

Also unified-only: `enumMapper` inside a `fieldConfig`, and `actionId` prefixed
`unified_`.

**What to write instead** — the same endpoint as a plain action:

```yaml
- actionId: list_groups          # not unified_list_groups
  actionType: list
  effects: [read]
  inputs: [...]                  # the provider's own parameters
  steps:
    - stepId: fetch
      stepFunction:
        functionName: request
        parameters:
          url: /scim/Groups
          method: get
          response:
            dataKey: Resources
  result:
    data: $.steps.fetch.output.data   # the provider's response, unchanged
```

No `fieldConfigs`, no `schema`, no `entrypointUrl`. Pagination goes in the step's `response`
and `iterator`, not in a top-level `cursor` block.

---

## The action envelope — what wraps `inputs`/`steps`/`result`

Seven keys are on **every one of 30,784 actions**. The first pass of this reference omitted
them entirely; they are not optional.

```yaml
- actionId: update_space
  label: Update Space                 # human name — 30,784 (100%)
  actionType: custom                  # 30,784 (100%)
  categories:                         # 30,784 (100%)
    - ticketing
    - organization
  tags:                               # 27,517 (89%)
    - Spaces
  resources: https://developer.clickup.com/reference/updatespace   # 28,619 — the doc URL
  description: Update an existing space.
  details: >-                         # 28,975 — longer behavioural notes
    Renames a space and updates its feature flags. Members are not affected.
  effects:                            # 29,018 (94%)
    - read
    - write
  inputs: [...]
  steps: [...]
  result: {...}
```

| Key | Count | Values seen |
|---|---|---|
| `actionType` | 30,784 | `custom` 27,956 · `event` 1,613 · `get` 546 · `list` 400 · `create` 96 · `update` 93 · `delete` 80 |
| `effects` | 29,018 | `read` 15,135 · `write` 9,045 · `delete` 3,288 · `execute` 930 · `search` 620 — a list; an update is `[read, write]` |
| `categories` | 30,784 | `hris` 3,729 · `ats` 3,201 · `crm` 2,601 · `iam` 2,451 · `ticketing` 2,090 · `marketing` · `documents` · `accounting` · `messaging` · `lms` |
| `resources` | 28,619 | URL of the provider doc page for this endpoint |
| `releaseStage` | 5,298 | `preview` 5,222 · `beta` 63 · `ga` 13 — absent means GA |
| `entrypointUrl` / `entrypointHttpMethod` | 943 each | **unified actions only — never write these** |
| `providerEvents` | 1,468 | on `actionType: event` actions — the provider's event names, e.g. `[worker.hire]` |

**`actionType` — use `custom` for everything except webhook handlers.**

The corpus splits: `custom` 27,956 · `event` 1,613 · `get` 546 · `list` 400 · `create` 96 ·
`update` 93 · `delete` 80.

The semantic values are ambiguous here. **All 683 unified actions use a semantic type and
none use `custom`** — so a semantic type is the shape unified actions take, and
`connector-guide.md` describes them as unified-only. 543 non-unified actions do use them
(Confluence, Box), so they are not exclusively unified, but since this flow never builds
unified there is nothing to gain by reaching for them.

- `custom` — every non-unified action, including plain CRUD. Always safe.
- `event` — required for webhook handlers, and the only non-`custom` value you need.

Note the corpus is not disciplined about this even where it uses semantic types: Confluence
labels `list_attachments` as `actionType: get`.

`inputs` is absent on 1,219 actions — an action with no parameters simply omits the key.

### Scopes — the corpus disagrees with the schema

`requiredScopes` appears on **12,387** actions, `authRequirements` on **1,401**. The shipped
corpus predates the migration, so most examples you will read use the deprecated form.
**Write `authRequirements`** — `requiredScopes` is a schema-review blocker. Do not copy the
majority pattern here.

The two shapes are not interchangeable. `requiredScopes` is a **bare string**;
`authRequirements` is a **map keyed by auth-block id with array values**:

```yaml
requiredScopes: root_readonly            # old — string

authRequirements:                        # new — map of auth id -> scopes
  oauth2:
    - write:folder:confluence
  custom: []
```

Event actions carry neither.

---

## Multi-step actions — 778 actions

Steps run in order; later steps read earlier output via `$.steps.<stepId>.output`.

```yaml
steps:
  - stepId: validate_connectivity
    description: >-
      GET /scim/Users?count=1 — confirm the bridge is reachable and the token valid before
      returning the synthesized record.
    stepFunction:
      functionName: request
      parameters: {...}
  - stepId: fetch
    stepFunction:
      functionName: request
      parameters: {...}
result:
  data: $.steps.fetch.output.data
```

Give every step a `stepId` you will reference, and a `description` when the reason for the
step is not obvious from the request alone.

---

## The main connector file

215 main files. Five keys are universal.

```yaml
StackOne: 1.0.0                    # 215
info:                              # 215
  title: ClickUp
  key: clickup
  version: 1.2.0
  description: Project management and productivity platform.
  assets:                          # 215 — always present
    icon: https://stackone-logos.com/api/clickup/filled/svg
  changelog: ...                   # 86
baseUrl: https://api.clickup.com/api/v2   # 215
authentication: [...]              # 215
actions: [...]                     # 215 — $ref entries
releaseStage: preview              # 213
resources: https://developer.clickup.com/docs   # 202
rateLimit: {...}                   # 111
scopeDefinitions: {...}            # 98
events: {...}                      # 38
```

> **The `actions` block repeats the `$ref` key** — one line per partial. That is invalid
> strict YAML, so any parser must tolerate duplicate mapping keys, and anything reading the
> refs must scan the raw text (a duplicate-tolerant parse keeps only the last). 264 of the
> 479 shipped connectors use this form.

### scopeDefinitions — 98 connectors

```yaml
scopeDefinitions:
  admin:read:
    description: Read access to admin API for users, learning objects, catalogs and groups.
  admin:write:
    description: Write access to admin API for creating, updating and deleting resources.
    includes: admin:read       # implies the read scope
```

`includes` expresses implication so an action needing `admin:write` does not have to list
`admin:read` as well.

---

## Authentication blocks

285 blocks across 215 connectors — `custom` 149, `oauth2` 136. Five keys are universal.

| Key | Count | Purpose |
|---|---|---|
| `type` `label` `support` `authorization` `environments` | 285 each | always present |
| `configFields` | 226 | end-user credentials |
| `testActions` | 186 | actions used to verify a connection |
| `refreshAuthentication` | 135 | token refresh |
| `setupFields` | 98 | developer-app credentials (client id/secret) |
| `grantType` | 79 | OAuth2 only |

**`authorization.type`:** `bearer` 96 · `oauth2` 89 · `none` 65 · `basic` 35

```yaml
authentication:
  - api_key:
      type: custom                 # custom | oauth2
      label: API Key (JWS HS256)
      support:
        description: >-
          Uses JWS with HS256 symmetric signing. Requires a Token ID and Secret Key from the
          Absolute Secure Endpoint Console.
        guides:
          config:
            warning: You must be signed in as a user with Manage permission for API Credentials.
            sections:
              - title: Select your API region
                content: Your API region corresponds to the console sign-in URL.
      authorization:
        type: bearer
        token: $.credentials.accessToken
      environments: {...}
      configFields:                # end-user supplies these — note `key`, not `name`
        - key: accessToken
          label: API Token
          type: password
          required: true
          secret: true
          description: Generate via Settings > API > Create Token.
      testActions:
        - action: get_all_users
```

**`setupFields` and `configFields` use `key`.** Action `inputs` use `name`. Two schemas, and
mixing them is the most common structural error in this file.

**All 13 `authorization` shapes, both field schemas, environments, refresh, rate limits and
webhook event routing are in `references/authentication.md`.**

`testActions` name real actionIds used to prove a connection works — pick read-only ones.

---

---

## Headers are args — 22,121 of them

Headers are not a separate block. They are args with `in: headers`, and they are usually
**literal values**, not drawn from inputs:

```yaml
args:
  - name: Content-Type          # 6,585
    value: application/json
    in: headers
  - name: Accept                # 4,272
    value: application/json
    in: headers
  - name: x-api-key             # 826
    value: $.credentials.apiKey
    in: headers
```

Most `POST`/`PUT`/`PATCH` actions carry an explicit `Content-Type`, and most actions carry
`Accept`. Other common names: `Authorization` 1,344 (when the auth block cannot express it),
`User-Agent` 269, and provider-specific ones such as `X-SmartToken`.

Note the contrast: only 633 **inputs** are `in: headers`, but 22,121 **args** are. A header is
normally a constant you set, not something the caller supplies.

---

## Hyphenated input names need bracket notation

An input named `include-collaborators` cannot be referenced with a dot — the hyphen parses as
a minus:

```yaml
inputs:
  - name: include-collaborators
    description: Includes collaborators on the folder.
    type: boolean
    in: query
    required: false

args:
  - name: include-collaborators
    value: $.inputs["include-collaborators"]                  # bracket — 413 uses
    in: query
    condition: '{{present(inputs["include-collaborators"])}}'  # in the condition too
```

Single quotes work equally: `$.inputs['User-Id']`.

646 inputs in the corpus have a hyphen in the name. 413 references use brackets correctly;
**32 use `$.inputs.Content-Type` and are silently broken** — the value never resolves. If a
provider reports a parameter missing that you are certain you sent, check this first.

---

## `authRequirements` — 1,401 actions

Keyed by **auth block id**, valued with the scopes that block needs for this action:

```yaml
authRequirements:
  oauth2_managed:
    - write:folder:confluence
  oauth2:
    - write:folder:confluence
  custom: []            # this auth type needs no scopes — still list it
```

The keys must match the ids in the main file's `authentication` block exactly; schema review
checks this. Observed keys are the auth ids themselves: `oauth2` 938 · `custom` 734 ·
`oauth2_managed` 196 · `api_key` 100 · `oauth2ForConfidential` 85 · `oauth2ForPublic` 85 ·
`access_token` 50 · `oauth2_bot` 48 · `oauth2_user` 48.

An auth type that needs no scope still gets an entry with an empty list — omitting it reads as
"unsupported" rather than "no scopes required".

---

## `response.collection: false` — 404 uses

Explicit on single-record endpoints, alongside `true` for bare arrays:

```yaml
response:
  collection: false
```

Not required, but common in connectors that set it on every action for symmetry.

---

## Checklist before the build gate

- [ ] `inputs` use `name`, not `key`
- [ ] Every input has `description` and `required`
- [ ] `in` on top-level inputs only, never on nested `properties`
- [ ] Every optional arg has `condition: '{{present(inputs.X)}}'`
- [ ] URLs interpolate with `${inputs.x}`; arg values use `$.inputs.x`
- [ ] Enums list **every** documented value
- [ ] `array: true` alongside `type`, with `properties` describing one element
- [ ] List endpoints have a `response` block — `collection`, `dataKey`, or `dataKey`+`nextKey`
- [ ] `result.data` points at `$.steps.<stepId>.output.data`
- [ ] Rules added wherever the provider documents a bound
- [ ] Every action carries `label`, `actionType`, `categories`, `effects`, `resources`
- [ ] `effects` matches what the action does — `[read]`, `[read, write]`, `[delete]`
- [ ] Scopes written as `authRequirements`, never `requiredScopes`
- [ ] Auth `setupFields`/`configFields` use `key`; action `inputs` use `name`
- [ ] Hyphenated input names referenced as `$.inputs["a-b"]`, never `$.inputs.a-b`
- [ ] `Content-Type` and `Accept` set as header args where the provider expects them
- [ ] `authRequirements` keyed by auth-block id, with `[]` for auth types needing no scope
