diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index 396c2d5b91a..7d36bfea333 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -456,6 +456,7 @@ retry): | MCP server unreachable / `tools/list` fails; remote spec URL unreachable or transiently failing (408/429/5xx) | **Not fatal**: the instance registers **degraded** (visible on `GET /api/v1/automation/connectors` with the reason), dispatches fail with that reason, and the platform retries on a 5s→5min backoff — recovery is automatic | | A retry of a degraded instance reaches the upstream and meets a configuration fault (an `include` naming a tool or operation the upstream does not offer) | **Not fatal**: the instance stays **degraded**, now with the configuration reason, and is **not retried** — dispatches fail saying to edit its declaration; an edit of the entry (or a restart) re-attempts it | | Wrong spec URL (other 4xx) or unparseable document | Configuration fault — **hard boot error** | +| A `connector_action` input that, once evaluated, breaks the action's `inputSchema` (a missing `required` parameter, a wrong `type`) | **The step fails before dispatch** with `VALIDATION_ERROR`, naming each parameter path, and nothing is sent; a `fault` edge or `try_catch` can route it. Supply the parameter or send the declared type: text is never converted, so cast it in a CEL envelope (`int()`, `double()`, `bool()`). `GET /api/v1/automation/connectors` lists the schema; `os validate` does not judge the input against the `inputSchema` yet | | A bad entry arrives via **runtime reload** (a Studio publish, a direct active save through `PUT /api/v1/meta/connector/:name`, or a delete, made on any replica — peers reconcile on [cluster lane 2](/docs/kernel/cluster#62-the-cluster-invalidation-family); or a dev reload) | Logged and **skipped**, never crashing the live server; a changed instance's old connector keeps serving until its replacement materializes | ## Catalog descriptors: entries without a `provider` diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index 58280edfa64..c77afb774ee 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -46,13 +46,12 @@ A **Flow** is a directed graph of nodes — the primary automation building bloc | `autolaunched` | Runs without user interaction — triggered by events, APIs, or other flows | | `screen` | Interactive — presents UI screens to the user (wizards, forms) | | `schedule` | Runs on a cron/interval cadence declared on the **start node's `config.schedule`** — or a **per-record date sweep** via `config.timeRelative`, see *Time-relative triggers* | -| `record_change` | Fires automatically on record create/update/delete (bind via the `start` node's `triggerType`). `autolaunched` + the same `record-*` binding behaves identically; `record_change` also opts into the trigger-readiness lint | +| `record_change` | Fires automatically on record create/update/delete. `autolaunched` + the same `record-*` binding behaves identically; `record_change` also opts into the trigger-readiness lint | | `api` | Inbound **webhook** — needs a start-node `secretRef` (or a literal `secret`; see *Inbound webhook triggers* below); a flow only ever started explicitly is `autolaunched` | ### Flow Node Types -Flows are built from **built-in node types** (plugins register more, e.g. -`approval`): +Flows are built from **built-in node types** (plugins register more): #### Control Flow @@ -85,7 +84,7 @@ Flows are built from **built-in node types** (plugins register more, e.g. |:-----|:--------| | `http` | Call an external HTTP API — always set `timeoutMs` (unset = no timeout). `url` and `headers` are served as written with the definition to every member who can read flows — only `signingSecret` and a start node's `secret` are withheld — so never a credential there: a credential means a declared connector called via `connector_action` | | `notify` | Send a notification through the messaging service (inbox by default) | -| `connector_action` | Dispatch an action of a declared connector (§ *Integrations are declared, not coded*) or a plugin's. `connectorConfig.input` is a value-slot map like `subflow` `input`; `os validate` does not judge it yet, so a malformed envelope fails at run time | +| `connector_action` | Dispatch an action of a declared connector (§ *Integrations are declared, not coded*) or a plugin's. `connectorConfig.input` is a value-slot map like `subflow` `input`: `registerFlow` and `os validate` refuse a `{…}` token at `connectorConfig.input.`. Once evaluated, it is judged against the action's `inputSchema` (`required`, `type`, `properties`, `items`) before dispatch: a breach fails the step `VALIDATION_ERROR` (`fault`-routable), nothing sent; `os validate` does not judge the `inputSchema` fit yet | | `script` | Call a **registered** function named by `config.function` (see *Valid-but-silently-wrong* #3) | | `screen` | Display a UI form to the user (screen flows only). `config.fields[]`: `{ name, label, type, required, options, defaultValue }`, `type` an open widget hint; `'file'` renders the upload widget and resumes with the `sys_file` id bound to `name` — a later `create_record` / `update_record` writes it into a `file` field, which claims the file: collect first, write after the last screen. `'record_link'` (never `'record'`, an object field type) takes no input — it shows the `reference` record whose id `defaultValue` evaluates to, by display name, as a link to its record page: the result screen of a flow that created records, `{ name: 'account_link', type: 'record_link', reference: 'crm_account', defaultValue: { dialect: 'cel', source: 'account.id' } }` over a `create_record` `outputVariable: 'account'`. Both keys required; `required` / `options` / `placeholder` / `min` / `max` refused; a resume value under its name refused (`400 INVALID_SCREEN_INPUT`). A completed `end` `message` is refused and a markdown link in a screen `description` renders literally. Next / Submit and "Step N of M" are not authored: the engine reads the graph when the screen pauses and serves `progress` (`nextScreen: 'none' | 'possible' | 'certain'`; `step` / `stepCount` only when every path agrees), which alone labels the button and draws the counter. A `decision`, `fault` edge, loop back, non-screen pause (`approval`, `wait`) or `subflow` after a screen answers `possible` — "Next", no count. `progress` in a node `config` is refused; never hand-roll a button label or step counter into `fields` | @@ -93,7 +92,7 @@ Flows are built from **built-in node types** (plugins register more, e.g. | Node | Purpose | |:-----|:--------| -| `approval` | Route a record for human sign-off — **suspends** the run until a decision, then continues down the `approve` / `reject` branch (from `plugin-approvals`) | +| `approval` | Route a record for human sign-off — **suspends** the run until a decision, then continues down the `approve` / `reject` branch | ### `notify` — the most-used node type @@ -193,13 +192,13 @@ type: 'text', isInput: true }, { name: 'approval_result', type: 'boolean', isOut A whole-string **query placeholder** (`{current_user_id}`, `{today}`, `{current_year_start}`) passes through verbatim for the query engine; a flow variable never shadows it, and only `filter` gets this hand-off. -- **An operand that evaluates to `null` refuses the node** (not - `fault`-routable): a dropped condition does not narrow a query, it widens - it, so a routed `delete_record` would empty the object while the run reports - success. Where the value may be absent, route around the node with a - `decision`; to match an empty field, write the literal `null`. A relation - hop (`record.owner.email`) needs the start node's `config.expand` to list - the field — else the fault names the missing hop, not the remedy. +- **An operand that evaluates to `null` refuses the node**: a dropped condition + does not narrow a query, it widens it, so a routed `delete_record` would empty + the object while the run reports success. Where the value may be absent, + route around the node with a `decision`; to match an empty field, write the + literal `null`. A relation hop (`record.owner.email`) needs the start node's + `config.expand` to list the field — else the fault names the missing hop, not + the remedy. --- @@ -244,8 +243,7 @@ write them right the first time: 3. **`script` nodes call a registered function — nothing else.** `config.function` (**required**: an empty node refuses at execute, an unregistered name fails - loudly) names one registered via `defineStack({ functions: { my_fn: (ctx) => … } })`; - `notify` delivers, `connector_action` reaches a vendor, a function computes. + loudly) names one registered via `defineStack({ functions: { my_fn: (ctx) => … } })`. **A flow `function` is a PURE compute step — it does NOT read/write the database.** It receives `ctx.input` and **returns** a value; `config.outputVariable` @@ -329,9 +327,9 @@ node's `config`. ### Prerequisite — declare the capabilities your nodes need -Metadata registers without its `defineStack({ requires })` token; the surface -just never runs. Two pairs are instead **refused** at `os validate` / `build` / -`dev` / `serve` (422); neither token implies the other: +Metadata registers without its `defineStack({ requires })` token. Two pairs are +instead **refused** at `os validate` / `build` / `dev` / `serve` (422); neither +token implies the other: | `requires` token | Turns on | Absent ⇒ | |:--|:--|:--| @@ -384,7 +382,7 @@ owns `label`, `type`, `nodes` and the `edges` `FlowSchema` requires): config: { objectName: 'support_case', triggerType: 'record-after-update', - // bare CEL; gates whether the flow launches on the event + // gates whether the flow launches on the event condition: "previous.status != 'escalated' && record.status == 'escalated'", }, } @@ -408,7 +406,7 @@ A vendor system reachable over HTTP (OpenAPI, remote MCP, plain REST) is a `connectors:` entry inside the app — `provider: 'openapi' | 'mcp' | 'rest'`, a packaged spec, `auth.credentialRef` (the customer's administrator supplies the value) — dispatched by `connector_action`; an inbound vendor callback is an -`api` flow. Never a plugin, never an `http` node holding a credential. Recipes, +`api` flow. Never a plugin. Recipes, credential hand-off, loader limits: [references/integration-recipes.md](./references/integration-recipes.md). Platform code only when one holds — and then it is the platform's, filed as a card, the app still writing none: the protocol is not HTTP; the work writes diff --git a/skills/objectstack-automation/evals/integrations-declared.json b/skills/objectstack-automation/evals/integrations-declared.json index a575bd17b2f..b9827409a40 100644 --- a/skills/objectstack-automation/evals/integrations-declared.json +++ b/skills/objectstack-automation/evals/integrations-declared.json @@ -4,7 +4,7 @@ { "id": 1, "prompt": "Our contract-lifecycle app (objects `contract`, `crm_account`) needs DocuSign: send a contract out for signature, read an envelope's status, and reflect status changes on the record. DocuSign publishes a 4.7 MB Swagger 2.0 document with several hundred operations, authenticates with OAuth 2.0, and its Connect webhooks sign posts with their own HMAC header. Propose what to build and where.", - "expected_output": "A declarative instance inside the app, not a platform plugin and not a `connector-esign` family: `defineConnector` with `provider: 'openapi'`, whose `providerConfig.spec` is a packaged OpenAPI 3.x document converted offline from the Swagger 2.0 document and trimmed to the three operations (create envelope, get envelope, list status changes) (or `providerConfig.include` names those three operation keys, the `mcp` provider's allowlist meaning, where a non-array or unknown entry is fatal; trimming still keeps the package small). Fields on `contract` (`envelope_id`, `signature_status`). Flows dispatch `connector_action`; the callback is an `api` flow with a start-node `secretRef`. Two gaps are named plainly, each blocked on its platform card: the declarative `auth` union has no `oauth2`, so the integration is marked **blocked** on outbound OAuth (no short-lived token parked behind a `bearer` `credentialRef`); DocuSign Connect signs its own header, which the `api` door does not verify, so status is polled until vendor schemes are verified. `connectorConfig.input` is a value slot: the per-contract payload reads the record through a CEL envelope (`envelopeId: { dialect: 'cel', source: 'record.envelope_id' }`). The answer proposes no `registerConnector` code and no plugin.", + "expected_output": "A declarative instance inside the app, not a platform plugin and not a `connector-esign` family: `defineConnector` with `provider: 'openapi'`, whose `providerConfig.spec` is a packaged OpenAPI 3.x document converted offline from the Swagger 2.0 document and trimmed to the three operations (create envelope, get envelope, list status changes) (or `providerConfig.include` names those three operation keys, the `mcp` provider's allowlist meaning, where a non-array or unknown entry is fatal). Fields on `contract` (`envelope_id`, `signature_status`). Flows dispatch `connector_action`; the callback is an `api` flow with a start-node `secretRef`. Two gaps are named plainly, each blocked on its platform card: the declarative `auth` union has no `oauth2`, so the integration is marked **blocked** on outbound OAuth (no short-lived token parked behind a `bearer` `credentialRef`); DocuSign Connect signs its own header, which the `api` door does not verify, so status is polled until vendor schemes are verified. `connectorConfig.input` is a value slot: the per-contract payload reads the record through a CEL envelope (`envelopeId: { dialect: 'cel', source: 'record.envelope_id' }`), judged against the action's `inputSchema` before dispatch. The answer proposes no `registerConnector` code and no plugin.", "files": [], "assertions": { "must_contain": ["defineConnector", "provider: 'openapi'", "credentialRef", "connector_action", "type: 'api'", "secretRef", "Swagger 2.0", "blocked"], diff --git a/skills/objectstack-automation/references/integration-recipes.md b/skills/objectstack-automation/references/integration-recipes.md index 2b139946076..68385555d19 100644 --- a/skills/objectstack-automation/references/integration-recipes.md +++ b/skills/objectstack-automation/references/integration-recipes.md @@ -16,12 +16,17 @@ deployment and to the sender. Scaffolded apps list the three executors in - Actions come from the upstream (an OpenAPI `operationId`, an MCP tool name, the `rest` provider's single `request`) — authored on the entry, they are refused. - `connectorConfig.input` is a value-slot map like `subflow` `input`: a top-level - CEL envelope is evaluated, any other value sent as written (`{…}`, `{{ }}` are - literal text) — `input: { customer: { dialect: 'cel', source: 'record.stripe_id' } }`. - `os validate` does not judge it yet, so a malformed envelope fails at run time. + CEL envelope is evaluated, any other value kept as written. `registerFlow` and + `os validate` refuse a `{…}` token at `connectorConfig.input.`: a value + from the record is a CEL envelope. Before dispatch the result is judged + against the action's `inputSchema`: `required`, `type` (with OpenAPI + `nullable`), `properties`, `items`; `null` on an optional parameter is + absent. A breach fails the step `VALIDATION_ERROR` (`fault`-routable) naming + each path, nothing sent. Text is never converted: cast a string-held id, + `path: { dialect: 'cel', source: '{"petId": int(record.vendor_id)}' }`. + `os validate` does not judge the `inputSchema` fit yet. - The answer lands as `.` (`rest` / `openapi`: `{ status, ok, body }`; - `mcp`: `{ ok, content, structuredContent? }`), read from a later node's value - slot: `{ dialect: 'cel', source: 'bal.body.available[0].amount' }`. + `mcp`: `{ ok, content, structuredContent? }`), read from a later node's value slot. - `auth`, each variant with a `credentialRef`: `bearer`; `api-key` (`headerName`, default `X-API-Key`, or `paramName` for a query-string key); `basic` (`username` stays in metadata). A secret in a url path has no variant — use a @@ -31,12 +36,12 @@ deployment and to the sender. Scaffolded apps list the three executors in ## 1 · OpenAPI document, static key — Stripe balance, nightly ```ts -// Trim the vendor's OpenAPI 3.x document to the operations the app calls and ship -// it in the package, package-relative. +// Trim the vendor's OpenAPI 3.x document to the operations the app calls; its path +// is package-relative. export const StripeConnector = defineConnector({ name: 'stripe', label: 'Stripe', type: 'api', provider: 'openapi', - providerConfig: { spec: './src/connectors/stripe.trimmed.json' }, // holds `GetBalance` alone - auth: { type: 'bearer', credentialRef: 'STRIPE_SECRET_KEY' }, // admin sets STRIPE_SECRET_KEY + providerConfig: { spec: './src/connectors/stripe.trimmed.json' }, + auth: { type: 'bearer', credentialRef: 'STRIPE_SECRET_KEY' }, }); // crm_account gains `stripe_available` (currency) and `stripe_synced_at` (datetime). export const StripeBalanceSweep = defineFlow({ @@ -63,7 +68,7 @@ dereference every `$ref` offline, in-document ones included: the loader skips a `$ref` parameter, request body or response, and the derived input/output schema silently loses it. `providerConfig.include` allowlists operation keys as the `mcp` provider's does (absent = every operation; a non-array or unknown entry is -fatal); trimming still keeps the package small. +fatal). ## 2 · Remote MCP server, static header — GitHub @@ -103,10 +108,10 @@ export const WarehouseApi = defineConnector({ ```ts export const CarrierCallback = defineFlow({ name: 'carrier_callback', label: 'Carrier status callback', type: 'api', - runAs: 'system', // an inbound post carries no trigger user + runAs: 'system', // no trigger user nodes: [ { id: 'start', type: 'start', label: 'POST …/hooks/carrier_callback/carrier', - config: { hookId: 'carrier', secretRef: 'CARRIER_HOOK_SECRET' } }, // never a literal secret + config: { hookId: 'carrier', secretRef: 'CARRIER_HOOK_SECRET' } }, { id: 'apply', type: 'update_record', label: 'Apply status', config: { objectName: 'shipment', filter: { tracking_no: { dialect: 'cel', source: 'record.tracking_no' } }, fields: { status: { dialect: 'cel', source: 'record.status' } } } }, @@ -127,7 +132,7 @@ name the gap and poll its status (recipe 1's shape) until vendor schemes verify. ```ts export const LegacyErp = defineConnector({ name: 'legacy_erp', label: 'Legacy ERP', type: 'api', provider: 'openapi', - providerConfig: { spec: { // inline OpenAPI 3.x + providerConfig: { spec: { openapi: '3.0.3', info: { title: 'Legacy ERP', version: '1' }, servers: [{ url: 'https://erp.example.com' }], paths: { '/v2/open-orders': { get: { operationId: 'listOpenOrders', @@ -138,16 +143,14 @@ export const LegacyErp = defineConnector({ // connector_action { connectorId: 'legacy_erp', actionId: 'listOpenOrders', input: {} } ``` -Each `operationId` is a named action with its input schema, visible in the flow -palette and to agents; `provider: 'rest'` instead when the path is per call. +Each `operationId` is a named action in the flow palette and to agents; +`provider: 'rest'` instead when the path is per call. ## Not yet — declare the rest, mark the integration blocked, write no plugin - **OAuth-protected vendor** (DocuSign, Salesforce, Google): the declarative `auth` union has no `oauth2`; the authorization-code / refresh lifecycle is not delivered in the open tier. Do not park a short-lived access - token behind a `bearer` ref. Declare the connector, fields and flows, mark the - integration **blocked** on the platform's outbound-OAuth card, and say so. + token behind a `bearer` ref. Blocked on the platform's outbound-OAuth card. - **Signature-authenticated vendor** (Alipay, WeChat Pay, Aliyun — RSA2 / HMAC - canonical-request signing): no auth kind signs requests today; same handling, - blocked on the request-signing card. + canonical-request signing): no auth kind signs requests today; blocked on the request-signing card.