Io Node Config — Automation Protocol reference
Config contracts for the flat IO builtins — notify and http. Reference for HttpConfig, NotifyConfig: every property with its type and default.
Config contracts for the flat IO builtins — notify and http (#4045).
Provenance — written from the executors, not from the forms
Each schema here was derived by reading what the executor actually does with
node.config (service-automation/builtin/notify-node.ts, http-nodes.ts),
not by transcribing the hand-written configSchema literal on the node's
descriptor. That independence is the point: the two artifacts are reconciled
bidirectionally by io-node-form-zod-ledger.test.ts in service-automation,
and a Zod copied from the form would make that reconciliation a tautology —
it would pass by construction and prove nothing (#4045).
What these schemas are wired to (#4277)
Like LoopConfigSchema / ParallelConfigSchema / TryCatchConfigSchema,
these are live execute-time contracts: each executor parse()s its
config against its schema before running (service-automation's
parse-config.ts), so type and required violations refuse the node as a
guard (not routable via fault edges). notify parses the RAW stored
config — its slots are string-typed or template-typed, so templates pass
({{ }} holes in its two text slots, {token} in the rest) and the
post-rendering guards still own "resolved to nothing". http parses the
INTERPOLATED config, because that is the shape
its executor reads — a {token} in a typed slot (timeoutMs, durable)
resolves to its real type first.
Unknown keys — closed here too, as of #4001 批 9
These contracts used to say "unknown keys are the registration layer's job":
registerFlow() rejects keys the descriptor configSchema does not declare
(the tightened #4059 check), and this parse merely stripped them. That is one
door, and the #4001 campaign's second recurring finding is that a schema
which strips by default leaves every OTHER door open — whoever writes the
guard is fixing the bug in front of them, not auditing the surface.
The registration check remains the first door a stored flow meets and the
more informative one (it walks NESTED config against the descriptor's JSON
Schema and prints the declared set per path, which a flat key list cannot).
What changes is that a config reaching parse() by any OTHER route — a
direct NotifyConfigSchema.parse() in tooling, a host that composes the
engine without registerFlow, a future executor seam — no longer has its
undeclared keys silently deleted. The two doors are kept in agreement by
io-node-form-zod-ledger.test.ts, which reconciles this key set against the
descriptor's in both directions.
connector_action has no schema here on purpose: its config contract is
empty. The executor reads only the declared FlowNodeSchema.connectorConfig
sibling block — see the descriptor note in
service-automation/builtin/connector-nodes.ts.
Source: packages/spec/src/automation/io-node-config.zod.ts
TypeScript Usage
import { HttpConfigSchema, NotifyConfigSchema } from '@objectstack/spec/automation';
import type { HttpConfig, NotifyConfig } from '@objectstack/spec/automation';
// Validate data
const result = HttpConfigSchema.parse(data);HttpConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| url | string | ✅ | Target URL |
| method | string | optional | HTTP method (default GET; POST when durable) |
| headers | Record<string, string> | optional | Request headers. The flow definition, this map included, is served to every member who can read flows, so never put a credential here: declare a connector whose auth is bearer or api-key (a header), with auth.credentialRef naming the secret, and call it from a connector_action node. |
| body | any | optional | Request body (JSON-serialised) |
| durable | boolean | optional | Fire-and-forget via the durable outbox (retry/dead-letter) instead of inline request/response |
| timeoutMs | number | optional | Per-request timeout (ms) |
| signingSecret | string | optional | HMAC-SHA256 secret → X-Objectstack-Signature |
NotifyConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| recipients | string | string[] | ✅ | Recipient user id(s) / audience selector(s); {token} templates resolve per run |
| title | string | { dialect: 'template'; source?: string; ast?: any; meta?: object } | optional | Notification title — a template: a bare string, or a { dialect: 'template', source } envelope (the tmpl helper) carrying the same text. It is rendered per run with {{ }} holes over the flow's variables — a variable path with an optional formatter ({{ record.name }}, {{ record.amount | currency }}); a single-brace {token} is refused. One text for every recipient (not localizable — use template for per-locale content). Either this or template is required; the two are mutually exclusive. |
| message | string | { dialect: 'template'; source?: string; ast?: any; meta?: object } | optional | Notification body — the same template input as title (a bare string or a { dialect: 'template', source } envelope), rendered per run with {{ }} holes like title; not localizable. Only valid with inline title, never with template. |
| template | string | optional | Email template name (sys_email_template.name, e.g. crm.large_deal_won) — the localizable content path: the delivery path resolves (name, locale) against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient's own sys_user.locale when set, else the deployment default locale — so recipients whose personal languages differ receive different rows of the same bundle. The node's payload.locale is not consulted. Mutually exclusive with inline title/message, which are the non-localizable path. Read raw — no {token} interpolation. |
| templateData | Record<string, any> | optional | Render context for the referenced template's {{var}} placeholders; values interpolate {token} templates per run. Only valid together with template. |
| channels | string | string[] | optional | Channels to fan out to (default: inbox) |
| topic | string | optional | Event topic (default: "notify") |
| severity | Enum<'info' | 'warning' | 'critical'> | optional | Severity forwarded to the messaging service |
| sourceObject | string | optional | Object name of the record the notification links to (writes sys_notification.source_object). Only takes effect together with sourceId — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link. |
| sourceId | string | optional | Record id the notification links to (writes sys_notification.source_id). Only takes effect together with sourceObject — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link. |
| actorId | string | optional | User id that caused the event (writes sys_notification.actor_id) |
| actionUrl | string | optional | Explicit click-through URL; overrides the link synthesized from sourceObject/sourceId |
| payload | Record<string, any> | optional | Extra template inputs merged into the notification payload |