Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions content/docs/automation/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
36 changes: 17 additions & 19 deletions skills/objectstack-automation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -85,15 +84,15 @@ 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.<key>`. 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` |

#### Human Decision

| 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

Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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 ⇒ |
|:--|:--|:--|
Expand Down Expand Up @@ -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'",
},
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
41 changes: 22 additions & 19 deletions skills/objectstack-automation/references/integration-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>`: 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 `<nodeId>.<key>` (`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
Expand All @@ -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({
Expand All @@ -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

Expand Down Expand Up @@ -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' } } } },
Expand All @@ -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',
Expand All @@ -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.
Loading