Skip to main content
Wildo.ai Coming soon

Providers, OAuth & external data

Call provider operations, connect customer accounts, and read, write back or synchronize the ERP and CRM a company already runs.

Read the film instead

Two capabilities the application needs, and the providers that can answer each. Transactional email: Resend, Postmark, SendGrid, Mailgun, Brevo, Mailchimp, Email preview. Model chat: Anthropic, OpenAI, Google, Mistral, Grok, Perplexity. A Provider selection picks one for each: Transactional email, Resend; Model chat, Anthropic; and in the Local environment, Email preview. Then the email primary changes from Resend to Postmark, and the application code does not change. "Choose the provider for each capability."

Your application describes a Message — an Invitation, To: a new member, Channel: Email. It passes through a Shared message contract, and the selected provider delivers it: the Email provider carries this one, while an SMS provider sits behind the same contract. "Describe the message. The provider delivers it." "Vendor-specific operations remain available when the work needs them."

A Vendor operation, Slack · Post a message, described as data: Method, POST; Path, /chat.postMessage; Inputs, channel and text; Authentication, OAuth token. The provider executor runs it step by step: Validate inputs, Compile the request, Resolve the credential, Call Slack — and a result comes back: Message posted. "Call a vendor operation, described as data." "An application can also add a provider of its own."

In Acme's Connections, an administrator chooses Connect Asana. Asana asks: Authorize access for Your application, to Read tasks and Create tasks, and the administrator chooses Allow. An Organization connection now exists, Asana · Acme. A call to Create a task names that connection; when the token expires, Token refreshed; and an administrator can Disconnect it. A Personal connection, One member's own account, is a separate choice. "Connect the accounts people already use." "Each call names its account: the application's, the organization's or a person's."

Now the other direction: an External tool wants to connect to Your application, its Authorization server. It follows four steps: Discover, Register, Ask the person, Receive a token. It registers by Dynamic registration or with a Client ID metadata document. The person sees a Consent screen — Research Assistant wants access; Returns to: research.example; Identity: name and email — and approves. The token reaches One agent endpoint only. A service can also act as a Machine client, with its own roles. "Make your application connectable." "Connected tools ask the person; services act under their own identity."

The application declares its Systems of record by name: odoo-fr and odoo-de, two instances of Odoo · JSON-RPC, and finance, spoken to over OData. Each instance has its own address and secrets, and is read with the application's own Service identity: OAuth client credentials. Your application, Owner: Acme, binds resources to them: Customers and Invoices are Read through, Products are kept as a Local copy. Every row belongs to Acme, the organization that owns the application. "Your ERP, as a system of record." "Read with the application's own identity; every row belongs to the organization that owns it."

Three ways for information to move, side by side: Deliver an event, which leaves the application as it happens; Read on demand, which asks the remote system and waits for its answer; Synchronize a local copy, which brings the records home. "Choose when information moves." "Each choice has its own freshness and recovery."

Out: a Task completed event becomes a Signed delivery to the Customer's endpoint, with its Delivery ID. When the answer is lost, it is Retried with the same ID, and a Delivery record keeps what happened. In: a Billing provider sends an event; Signature verified, and only then does the Billing handler receive it. "Events out, events in." "A retry keeps its delivery identity; a vendor's event is verified before it is handled."

The painting from this page: Your application trading with Services, sending Events to Customer systems, and reading External records into a Local copy. "Let systems work together without losing their responsibilities." Wildo.ai

> From a service choice to its working contract > From business actions to connected systems > From the company’s ERP and CRM to the work built on them

An integration connects your application to a service or system outside it: sending a message, receiving a payment event, calling a vendor API or working with the records a company keeps in its ERP and CRM.

Wildo brings those connections into the application’s configuration. Providers supply services such as email, payments and vendor APIs, with their contracts and credentials. Events notify the systems around the application. Systems of record — Odoo, SAP S/4HANA, Salesforce, Microsoft Dynamics 365 (Dataverse and Business Central), NetSuite or any OData service — are declared once by name, then read in place, written back, copied or kept in sync. You choose the services, the information exchanged and the business behavior around them.

Make the connection part of the application

Use a vendor without tying the work to it

Standard email and SMS services use a shared message contract, while a vendor’s own API stays available when the work needs it. Each call uses the application’s account or an authorized customer connection, chosen explicitly for the work.

Let other systems follow the work

Declared business actions notify customer endpoints with signed, retried deliveries, and a provider’s callbacks arrive through a verified route. Each direction keeps its own delivery and recovery contract.

Build on the company’s systems of record

Declare an ERP or CRM once, by name, and each environment supplies its address and service account. Read its records in place, change them there, keep a local copy current or keep both sides in step, with each choice’s freshness and ownership stated.

Example: Bring existing company records into a new workflow

An application keeps a copy of its client accounts from the company’s Odoo, posts draft invoices there and adds its own task workflow. Its email provider sends invitations, while declared task events notify a customer’s endpoint. The application chooses the workflow; each integration keeps its own account, data and delivery contract.

For engineers

Choose an engine service, a vendor operation or a system of record

These are different ways to reach another system. Transactional email supplies a framework capability through a common message contract. A vendor API call names a provider reference directly; it does not need an invented engine capability. A company’s own ERP or CRM is neither: it is a system of record, declared under systemsOfRecord, never selected by capability and never reached through a person’s connected account.

The following combines selected provider declarations from Wonder Todos’ wildo.saas.config.ts. Unrelated providers, comments and configuration are omitted; the surrounding application enables transactional email and imports EngineCapability, defineSaaSProviders and its generated catalogue type.

providers: defineSaaSProviders<WildoDiscoveredProviderCatalog>({
  scopes: {
    backend: {
      providers: {
        resend: {
          engineCapabilities: [EngineCapability.EMAIL_TRANSACTIONAL, EngineCapability.EMAIL_INBOUND],
          providerCapabilities: ['EMAIL_TRANSACTIONAL', 'EMAIL_INBOUND'],
          protocols: ['EMAIL_PROVIDER', 'EMAIL_INBOUND_PROVIDER', 'WEBHOOK_ORIGINATOR'],
        },
        asana: { engineCapabilities: [], providerCapabilities: [], protocols: ['REST_API'] },
      },
      selection: {
        [EngineCapability.EMAIL_TRANSACTIONAL]: {
          primary: 'resend',
          whenUnavailable: [],
        },
      },
    },
  },
}),

The Resend declaration makes it eligible for the backend’s sending and inbound email services; the selection names it primary for transactional email. The Asana declaration makes its REST operations available to a call that names asana. Empty capability arrays here describe direct use, not an empty integration.

Odoo is declared in a separate block of the same file, with its comments and privacy-notice declaration omitted here:

systemsOfRecord: {
  odoo: {
    vendor: 'odoo',
    dialect: HttpApiTransportDialect.ODOO_JSONRPC,
    partition: true,
  },
},

The name odoo identifies this instance, and a resource binding names it with system: 'odoo'. Its address and service credential come from each environment under names derived from that name, and partition: true confines rows to the organization units an operator assigns each Odoo company to. The systems-of-record library, @wildo-ai/systems-of-record, describes seven vendors — odoo, sap-s4hana, salesforce, netsuite, microsoft-dataverse, microsoft-dynamics-365-business-central and a generic odata — and startup refuses a declaration its vendor cannot honour. The Systems of record section compares what each one supports.

Neither block contains a credential value. Supply each provider’s required material through the appropriate deployment or connected-account configuration, and each system of record’s through its environment. Discovery and synchronization produce the authoring catalogue and backend runtime artifacts; browser activation separately needs its public configuration and an explicit executable module map.

Match the mechanism to the direction of the work

NeedConnectionImportant application decision
Send a standard transactional messageSelected email or SMS providerMessage, sender and triggering business action
Call a vendor-specific APIProvider reference and named operationInputs, account authority and result handling
Notify a customer systemOutbound event and subscribed endpointEvent scope, enabled destinations and receiver behavior
Receive a vendor eventProvider callback and verification contractEnabled protocol, credentials and owning handler
Read system-of-record records as neededRead-through resource bindingSystem of record, remote identity and query mapping
Change those records, or run an ERP actionOpened write verbs or a declared action on the bindingWhich verbs and actions, the concurrency stance and the roles allowed
Maintain a local copyInbound external-data pipelineField ownership, schedule and reconciliation policy
Send local records, or keep both sides in stepOutbound or two-way pipelineWhich side owns each field and how a conflict is decided

An outbound notification’s retry policy is not a provider-selection fallback. A callback’s signature scheme belongs to the sending provider. A local copy has a synchronization lifecycle; a read-through view depends on the remote system for each read, and a write to it is sent once, never retried by the engine. Keep those distinctions in the application design.

Follow a call beyond its declaration

A direct provider operation goes through ProviderOperationExecutorBackendService. It resolves the provider’s operation catalogue, validates inputs, compiles the request and obtains access through the shared credential resolver before executing the call. The application still decides when to invoke it and what the response means for its business process.

The vendor operation example follows an Asana call from its declared backend scope through an explicit account target to a validated task reference. Use that path for vendor-specific work; transactional SMS shows the shorter service call when a standard engine contract fits.

The executor does not automatically traverse every page of a remote collection. Its pagination metadata informs the caller; a resource binding or pipeline has its own extraction contract. Read/write classification also matters for retry behavior: replaying a read and repeating a business action are different decisions.

For customer webhooks, the delivery record and receiver response show what happened after dispatch. The receiver must verify the signature and handle duplicate delivery identities. For imported data, review the completed population and ownership policy before enabling destructive reconciliation; an incomplete extraction does not establish that a remote record was deleted.

Keep the connection’s boundaries explicit

Use the runtime that owns the account and credential. Browser-safe provider modules carry public configuration; secret-aware execution belongs on the server. A provider’s declared capability or optional SDK requirement does not itself establish a working account, installed dependency or permission at the vendor. Likewise, a declared system of record establishes neither the service account’s permissions at the vendor nor a reachable address.

The detailed guides below connect these choices to their actual configuration and consumers. Validate the intended exchange with the configured service and receiver: a catalogue entry proves what is described, while a successful request demonstrates that particular connection.

Make provider choices part of the application model

A provider supplies a service or shared configuration for your application. It can connect an external vendor, run locally or supply settings used by standard controls. Wildo describes what it offers and which runtimes may use it.

Your application chooses its services and supplies their configuration. The shared contracts keep those choices from spreading vendor-specific code throughout the product.

The application selects local preview or live service from its declared providers.

Keep the choice connected to its consequences

Describe the need clearly

An application capability, a provider’s abilities and its protocol have distinct meanings. The declarations connect them without turning every integration into the same kind of request.

Choose within the runtime

Declare eligible providers for the backend, companion or browser service, then select a primary where appropriate. Additive destinations keep their fan-out behavior.

Keep the boundary visible

Environment selections can change the provider without rewriting the feature. Separate server and browser modules keep credential-aware execution apart from public configuration.

Example: One invitation flow, two environments

The application declares a sending email provider and a non-delivering preview provider. Local configuration selects preview; the application’s default selects the sender. The invitation flow keeps its message contract while the environment changes which implementation answers it.

For engineers

Connect discovery, scope and configuration

A provider package contributes its metadata and runtime entrypoints through the supported discovery contract. wildo config sync produces the authoring catalogue and runtime artifacts. The application then declares which capabilities and protocols each scope may use.

These selected Wonder Todos declarations show the backend email candidate and its selection, with unrelated providers omitted:

providers: defineSaaSProviders<WildoDiscoveredProviderCatalog>({
  scopes: {
    backend: {
      providers: {
        resend: {
          engineCapabilities: [EngineCapability.EMAIL_TRANSACTIONAL],
          providerCapabilities: ['EMAIL_TRANSACTIONAL'],
          protocols: ['EMAIL_PROVIDER'],
        },
      },
      selection: {
        [EngineCapability.EMAIL_TRANSACTIONAL]: {
          primary: 'resend',
          whenUnavailable: [],
        },
      },
    },
  },
}),

The enclosing application also enables transactional email. Its provider credential is supplied on the server; this declaration does not contain the secret. A browser provider follows its own service scope and public/code composition.

Ask the next question at the correct layer

QuestionOwning layer
Does the application use this framework feature?Engine capability activation
Can this provider satisfy it?Provider capability and runtime protocol contract
May this process use it?Runtime scope declaration and generated artifact
Which candidate answers?Capability resolution mode and selection
Does this environment choose differently?Infrastructure provider selection
How is the vendor request encoded and interpreted?The provider’s executable contract

A primary and its whenUnavailable entries establish resolution order. They do not retry another vendor after an operation fails. Audit fan-out instead resolves an additive set of configured destinations. Keep these semantics visible when reasoning about availability or delivery.

Verify the configured path rather than its label

Inspect the generated runtime artifact and resolved provider for the intended process. Confirm required credentials and protocol settings are available, then exercise the actual operation. A provider catalogue entry establishes discoverability; a configured selection establishes intent; a successful request establishes behavior on that path.

A company’s ERP or CRM is not a provider. It is declared under systemsOfRecord, and a resource binding names that instance (system: 'odoo') rather than selecting an engine capability or a provider; it is reached with the application’s own service identity, never a person’s connected account. The systems of record section explains that path.

Understand the connection

Separate what you need from who provides it Mechanism

An application need, a provider’s abilities and its connection method answer different questions. Wildo records each explicitly, so choosing an email service is distinct from enabling email or describing how that service receives a message.

You declare the providers the application uses. Wildo connects their declared abilities to the framework features that consume them.

Example: An email service with a clear role

A product needs transactional email. Its backend declares a provider that supports that need and the email connection contract. The same product can use a different provider for language models without coupling those decisions.

An email need, the selected provider and its delivery contract are distinct connected concepts.
For engineers
Read the declaration as three separate decisions

Wonder Todos enables EngineCapability.EMAIL_TRANSACTIONAL in engineCapabilities, then declares Resend under providers.scopes.backend.providers. This selection from that declaration, with Resend’s inbound-email capability and protocols omitted, states what that runtime may use for sending:

resend: {
  engineCapabilities: [EngineCapability.EMAIL_TRANSACTIONAL],
  providerCapabilities: ['EMAIL_TRANSACTIONAL'],
  protocols: ['EMAIL_PROVIDER'],
},

The engine capability names the framework need. The provider capability records an ability the provider contributes. The protocol selects its executable connection contract. defineSaaSProviders validates declarations against the discovered catalogue; the backend registry filters them again for its active scope.

Understand where the ability comes from

The provider module owns the corresponding ability declaration. Resend’s backend module includes:

Selected from resend.backend.module.ts; a comment and the webhook-secret and development-override entries of the secrets contract are omitted.

providerCapabilities: [
  BUILTIN_PROVIDER_CAPABILITY.EMAIL_TRANSACTIONAL,
  BUILTIN_PROVIDER_CAPABILITY.EMAIL_INBOUND,
],
secretsContract: {
  apiKey: {
    envVarName: "RESEND_API_KEY",
    required: true,
    description: "Resend API credential for email delivery, receiving and domains."
  },
},

The module’s protocol binding then supplies the email implementation. The application supplies the credential through its supported secret configuration and selects the provider for the enabled capability. The shared projection map derives engine capabilities from provider abilities rather than maintaining a second vendor-specific slot list.

Know when a capability selection is the wrong abstraction
IntegrationHow it is addressed
Transactional email or a language modelA provider serving the enabled engine capability in the runtime scope
A vendor-specific REST operation, such as creating an Asana taskThe provider reference and operation the application calls
A business system such as an ERP, read or written as a resourceThe named system-of-record instance the resource binding’s system field points at
Organizations or application language supportEngine-implemented activation, not an external provider selection

A REST vendor module can therefore declare empty capability lists and still expose its REST protocol. Do not invent a slot merely to make every integration look alike. The capability vocabulary is closed: an application cannot add its own capability names, so a need it does not name is a decision to widen the framework’s vocabulary together with the code that consumes it. The capability map now checks that each engine capability is either mapped or explicitly engine-owned; adding an unaccounted capability is a compile-time error.

Provider tier and origin are additional declared metadata. They describe the provider’s role and provenance, not whether your application has enabled or configured it. Keep those questions separate when tracing why a provider is available.

Give each connection a clear contract Mechanism

Email, model calls, incoming events and REST requests have different inputs and execution behavior. Wildo gives these connection types named contracts instead of treating every provider as an interchangeable URL.

A provider implements the contract appropriate to its work. The application chooses the provider and the configuration that the contract requires.

Example: Chat and document reading are separate choices

A product uses one model for conversation and another provider to extract text from documents. Separate connection contracts let those choices evolve independently, even when a vendor offers both services.

An application uses different connection contracts for email, chat, extraction and events.
For engineers
Match the authoring protocol to the executable binding

A backend module pairs its declared protocol with a discriminated runtime contract. This selected Resend binding shows the email path and encoding inside that contract; request-builder and response-parser implementations are omitted:

{
  protocol: "EMAIL_PROVIDER",
  runtimeContract: {
    kind: ProviderProtocolRuntimeKind.EMAIL,
    baseUrl: "https://api.resend.com",
    apiKey: {
      secretRef: "RESEND_API_KEY",
      headerName: "Authorization",
      prefix: "Bearer "
    },
    operations: ["sendEmail", "sendBatch"],
    supportedEmailTypes: ["transactional"],
    sendEndpointPath: "/emails",
    payloadEncoding: EmailProviderPayloadEncoding.JSON,
    // buildSendRequest and parseSendResponse complete this binding.
  },
}

This is an explanatory selection from the current module, with compacted arrays. A complete email binding also implements its request builder and response parser. Composition checks that the authoring key and runtime kind agree; a protocol name alone does not install executable behavior.

Select a contract that describes the work

ExternalProvider_ExchangeProtocol_Kind owns the protocol vocabulary. Backend runtime kinds are a subset of its values, so dispatch can discriminate the relevant contract without translating between unrelated identifiers.

WorkContract distinction
Generate conversationLanguage-model runtime
Create vectors for retrievalEmbeddings runtime, selected separately from chat
Extract text from document bytesDocument-extraction runtime
Receive vendor eventsWebhook-originator binding and its ingress consumer
Run browser integration codeFrontend SDK module, outside the backend runtime union

Application configuration selects the protocol in its provider scope and supplies protocol-specific settings where required. Keep the provider’s runtime module reachable through the generated artifact and its package entrypoint.

Extend the implementation as well as the vocabulary

When adding a vendor for an existing contract, implement the provider-specific details in its module and test the relevant consumer. Naming an additional protocol requires an executable contract and a consumer; a declared identifier is not evidence of supported behavior. Audio and video identifiers remain reserved rather than runnable backend contracts.

Keep vendor formats out of business code Mechanism

The framework’s email and SMS paths use common message contracts. Provider modules translate those messages into their vendor’s request format and interpret the response back into a shared result.

Your product can keep its message intent while the integration owns authentication, encoding and vendor-specific response meaning.

Example: The same message through a different email provider

An invitation still has recipients, a subject and content when its email provider changes. The selected module builds the vendor request; the application does not add another vendor-shaped payload to the invitation flow.

A common message passes through an adapter to the selected provider.
For engineers
Distinguish the application call from the provider request

The application’s email service starts from a registered template, recipients and context. After rendering, it creates the standard provider request. This illustrative object shows that lower-level contract, not a replacement for the template service:

import type { StandardEmail_SendRequest } from '@wildo-ai/external-connectors-models';

const submission: StandardEmail_SendRequest = {
  submissionId: 'invitation-example-001',
  to: [{ email: 'reader@example.com' }],
  from: { email: 'notifications@example.com', name: 'Example product' },
  subject: 'Your invitation',
  text: 'You have been invited to join the workspace.',
  headers: { 'X-Product-Message': 'invitation' },
};

For real delivery, use a verified sender and an appropriate stable identity for the logical submission. A fixed example identifier must not be reused for unrelated messages. submissionId supports correlation across retries; it does not promise every provider offers exactly-once delivery.

Keep transport and message headers separate

Resend’s request builder maps message fields into its JSON payload and places the submission identity in a transport header. The relevant ending is:

payload: {
  headers: request.headers,
  tags: request.tags?.map((tag) => ({ name: tag, value: tag })),
  scheduled_at: request.scheduledAt,
},
transportHeaders: {
  'Idempotency-Key': request.submissionId,
},

The API header authenticating or identifying the submission is distinct from headers carried inside the email. The engine serializes the builder’s declared payload encoding; callers do not branch on the vendor name to produce a different body.

Interpret acceptance at the provider boundary

The provider response parser translates receipt details into a shared submission classification. Accepted, rejected and acceptance-unknown are different results: a successful submission receipt is not proof that a person received or read the email. The non-delivering preview provider uses its own delivery contract rather than pretending a network send occurred.

Standard email and SMS contracts do not make every optional vendor feature identical. Select a provider appropriate to the needed operation, credentials and message shape. Other integration families, such as billing and models, have their own runtime contracts rather than this message contract.

Choose where each service runs

Choose who answers, or send to every destination Mechanism

Some integration needs should use one provider. Others, such as additional audit destinations, need every configured destination to receive the event. Wildo records that distinction in the capability itself.

Declare the providers each runtime may reach, then select a primary where the capability calls for one.

Example: One email service, several audit destinations

A product selects one transactional email provider for a message. Its configured audit sinks receive the event as additional destinations, rather than competing to become the primary sink.

Email selects one provider; an audit event goes to both configured archives.
For engineers
Declare candidates before choosing the primary

Wonder Todos declares providers under providers.scopes.backend.providers, then adds this selected part of the backend selection map:

[EngineCapability.AI_LLM]: {
  primary: 'anthropic',
  whenUnavailable: ['openai', 'google'],
},
[EngineCapability.EMAIL_TRANSACTIONAL]: {
  primary: 'resend',
  whenUnavailable: [],
},

Each selected provider must be discovered, declared for that runtime and able to serve the enabled capability. Backend, companion, browser services and workers have distinct scopes. A provider being present elsewhere in the application does not make it a candidate for this process.

Read substitutes as resolution order

The current field is whenUnavailable. It orders eligible substitutes after the primary during provider resolution; it does not retry a second vendor after the first vendor’s call fails. The registry filters candidates by declared capability and runtime scope, places eligible primary/substitute references first, then retains the other eligible references.

The select-one consumer takes the first resolved provider. Do not use a selection list as a claim of delivery redundancy. Application-specific retry or recovery must account for the actual operation and whether a failed call may already have been accepted.

Use fan-out for additive destinations

The capability resolution map assigns COMPLIANCE_AUDIT_TRAIL to FAN_OUT. The composite audit sink asks the registry for the resolved set and sends to the configured destinations. The application does not author a primary for this capability.

ModeResult of resolutionAppropriate expectation
Select oneOne resolved providerOne provider answers the operation.
Fan-outThe eligible destination setEach configured destination participates.
Substitute orderCandidate priority before executionAnother eligible provider can be selected when the primary is absent from the candidate set.

The mapping is exhaustive over engine capabilities, so adding one requires choosing its resolution mode. Inspect the runtime’s resolved selection when validating setup; declared names alone do not establish that a remote service is reachable or that a send succeeded.

Observe and recover each destination independently

Selected from CompositeAuditLogSink.recordAuditEvent; sink resolution and the empty-set return are omitted. Each sink write has its own catch, so one rejected write does not cancel the other attempts.

await Promise.all(
  sinks.map(async ({ ref, sink }) => {
    try {
      await sink.recordAuditEvent(event);
    } catch (err) {
      this.metricsService.recordAuditSinkFailure({ providerRef: ref });
      console.error('[audit] provider sink failed (non-fatal)', {
        providerRef: ref,
        ...projectCaughtErrorLogFields(err),
      });
    }
  }),
);

The failure metric identifies the destination that needs attention. A returned fan-out call is not an acknowledgment from every remote destination. This boundary catches individual sink-write failures; it does not make every surrounding operation infallible.

Recovery depends on the sink contract. The completeness reconciler replays database events for sinks that implement both checkpoint methods; a fire-and-forget SIEM webhook is not in that checkpointed set. Configure monitoring and recovery for the actual destination rather than assuming fan-out provides one universal retry or exactly-once guarantee.

Use the right provider for each environment Mechanism

The application can keep one provider declaration while an environment selects a different primary. Local email can use a non-delivering preview service while the application’s default selects a sending provider.

Put that choice beside the environment’s other configuration, rather than adding environment branches to message code.

Example: Preview invitations locally

A developer tests an invitation with the local preview provider. The application declares both preview and sending providers; the local environment selects preview, while environments without that override retain the application selection.

The same application selects local preview or a live service according to its environment.
For engineers
Declare the alternative in the application first

An override selects from the providers admitted by the application’s runtime scope. Wonder Todos declares email-preview alongside Resend in its backend providers:

'email-preview': {
  engineCapabilities: [EngineCapability.EMAIL_TRANSACTIONAL],
  providerCapabilities: ['EMAIL_TRANSACTIONAL'],
  protocols: ['EMAIL_PROVIDER'],
},

The application’s backend selection keeps resend primary. Its local infrastructure file, infrastructure/local/wildo.infra.local.config.ts, then contains:

providerSelection: {
  [EngineCapability.EMAIL_TRANSACTIONAL]: {
    primary: 'email-preview',
  },
},

These are actual configuration selections. The preview provider has a non-network delivery contract; it does not become a sending provider merely because it serves the transactional email capability.

Follow the environment choice into the process

The environment variable builder serializes the selection into WILDO_PROVIDER_SELECTION_OVERRIDES. The backend registry reads and caches that value, applying it ahead of the application selection for select-one capabilities. Use the normal configuration/environment synchronization and process startup path so the running process receives the intended environment.

Changing the infrastructure source is not a live mutation of an already initialized registry. Inspect the resolved runtime selection after restarting the relevant process with its updated configuration.

Keep the override’s authority narrow

The override cannot install a new provider or grant a provider to a scope where it was not declared. Resolution still filters eligible candidates, then applies selection ordering. Fan-out capabilities retain their additive destination set rather than treating an environment primary as a destination filter.

Malformed override data is ignored by the runtime’s defensive parser, so validate the actual selection rather than assuming a malformed local value guarantees non-delivery. whenUnavailable has the same selection-time meaning here as in application configuration; it is not retry-on-send-failure.

For a local non-delivery expectation, verify the intended preview provider is the resolved primary and observe the preview result before using real recipient addresses.

Keep server credentials out of browser integrations Mechanism

A provider can contribute server behavior and browser behavior without putting them in the same module. The server side owns credential-aware execution; the browser side carries its public configuration and supported browser code.

Shared provider identity connects the parts. Their package and runtime boundaries keep their different responsibilities explicit.

Example: The public and private halves of form protection

A website form asks a captcha service for a token in the visitor’s browser, using a public site key. The server checks that token with the service using a secret key the browser never sees. One provider reference joins the two halves.

A provider has separate server credentials and browser public settings.
For engineers
Give each runtime its own module

reCAPTCHA has one module per runtime under the same recaptcha reference. The website module carries only public configuration, and its schema says what must never be put there:

Selected from recaptcha.frontend-website.module.ts; the consent purpose, default public configuration and a comment between these lines are omitted.

providerCapabilities: [BUILTIN_PROVIDER_CAPABILITY.FRONTEND_CAPTCHA],
protocols: [BUILTIN_PROVIDER_PROTOCOL.FRONTEND_SDK],
publicConfigSchema: defineFrontendPublicConfigSchema({
  integrationKind: { kind: FrontendPublicConfigValueKind.STRING, description: 'Which integration: captcha.' },
  websiteRuntime: { kind: FrontendPublicConfigValueKind.BOOLEAN, description: 'Runs on the website surface.' },
  siteKey: { kind: FrontendPublicConfigValueKind.STRING, description: 'The reCAPTCHA v3 SITE key (public by design). Never the secret key.' },
}),

The backend module holds the secret and builds the verifier from it:

Selected from recaptcha.backend.module.ts; metadata, compliance profile, the empty protocol list and the verifier factory are omitted.

providerCapabilities: [BUILTIN_PROVIDER_CAPABILITY.FRONTEND_CAPTCHA],
secretsContract: {
  secretKey: {
    envVarName: 'RECAPTCHA_SECRET_KEY',
    required: true,
    description: 'The reCAPTCHA v3 SECRET key, shared only between this backend and Google, used to verify tokens.',
  },
},

Both declare the same capability, because a captcha is one capability whose token is minted in the page and verified on the server. wildo config sync also refuses to serve a public value equal to any of the environment’s secrets, so a secret pasted into public configuration stops the synchronization instead of reaching a browser.

Not every vendor has two halves. Sign-in and checkout are redirects that run no vendor code in the page, so Google sign-in and Stripe have server modules only, and the page shows the vendor’s name from the server’s configuration.

The backend module belongs to @wildo-ai/external-connectors-private/backend and exposes server runtime contracts and a secrets contract. Secret declarations name the required values; the application supplies those values through the server’s supported credential path. Do not copy secret values into publicConfig or a frontend contribution.

Separate browser data from executable code

Browser integration has two inputs. The application configuration or generated JSON supplies enabled provider data for the service and environment. A code map that configuration synchronization generates for each frontend service supplies executable code from the provider package’s browser facet.

ChannelCarriesWhy it is separate
DataIdentity, public configuration, SDK/component descriptors and CSP footprintCan be serialized for the selected environment.
CodeSDK activation, public-config validation and component loaders where supportedFunctions cannot survive JSON serialization.

hydrateFrontendProviderModules joins those inputs by provider reference. Enabled data without a matching module retains its identity and public configuration, but has no executable SDK and raises a missing-code diagnostic. Bundled code without enabled data remains inactive. Descriptor checks identify stale generated declarations rather than silently treating mismatched data as the current module.

Preserve the boundary in application code

Configuration synchronization writes the code map for each frontend service, src/generated/frontend-providers.generated.ts, importing every provider the service declares from its deliberate browser entrypoint for that surface. The application passes it through the host’s frontend registration option: for the SaaS application host, frontendProviderModules in its startup options, which startup passes into registry hydration alongside the enabled provider data. Rerun synchronization after changing a declaration rather than editing the generated map. Do not import the server package into a React component to obtain metadata. Shared browser-safe metadata exists for that purpose.

Package separation and the public package’s bundle-isolation tests establish an intended boundary, not a magic guarantee that an application can never write an unsafe import. Review custom provider dependencies and the actual browser artifact. Public configuration must remain public, and the server must still enforce access to every credential-backed operation.

See browser activation for the connected contribution and startup example: public settings enter through the companion contribution, while the browser imports its module through a public frontend entrypoint and passes it to its host.

Configure from the available providers

Configure from the providers your application knows Mechanism

Configuration tooling discovers provider contributions and generates the catalogue used while authoring. The editor can offer provider references, capabilities and protocols from that catalogue instead of asking you to remember their names.

The same discovery feeds provider data and backend runtime artifacts. Add a contribution at its owning package, then synchronize the application to make the new definition available.

Example: A new integration appears in configuration

A team adds a provider package with the expected contribution. After synchronization, its reference becomes available to the application’s typed provider declaration and its runtime reach can be configured.

Provider package discovery supplies configuration information and runtime declarations.
For engineers
Generate the catalogue, then consume its type

From the SaaS application root, run the configuration workflow:

wildo config sync

The generated .wildo-saas/generated/provider-catalog.types.ts exports WildoDiscoveredProviderCatalog. Wonder Todos imports that type and passes it to defineSaaSProviders. This reduced configuration shows the authoring connection:

import { defineSaaSProviders } from '@wildo-ai/platform-config-lib';
import type { WildoDiscoveredProviderCatalog } from './.wildo-saas/generated/provider-catalog.types';

const providers = defineSaaSProviders<WildoDiscoveredProviderCatalog>({
  scopes: {
    backend: {
      providers: {
        resend: {
          engineCapabilities: [EngineCapability.EMAIL_TRANSACTIONAL],
          providerCapabilities: ['EMAIL_TRANSACTIONAL'],
          protocols: ['EMAIL_PROVIDER'],
        },
      },
      selection: { [EngineCapability.EMAIL_TRANSACTIONAL]: { primary: 'resend' } },
    },
  },
});

This example assumes EngineCapability is imported from @wildo-ai/saas-models and transactional email is enabled in the enclosing SaaS configuration. Place the resulting declaration in that configuration’s providers field. The type import does not pull a provider SDK into the configuration module.

Separate editor checking from runtime validation

defineSaaSProviders<WildoDiscoveredProviderCatalog> checks provider references and each provider’s capability/protocol values against the generated catalogue in TypeScript. That catalogue is a type import: it is erased at runtime. The helper then parses the general provider configuration shape with Zod; it does not repeat the catalogue-specific type check using a hidden runtime catalogue.

Keep both checks: typecheck the authored configuration after synchronization, then verify the runtime has loaded the declared provider and can resolve its access. Valid configuration syntax does not supply credentials or prove that an account may perform an operation.

Make the provider discoverable at its owner

Discovery follows the application’s admitted service, library and provider-package contribution contracts. Built-in engine providers are discovered through their package’s companion export; application scopes choose their runtime reach. An application-owned provider supplies a contribution from its own package, including its module entrypoint and supported runtime targets.

Do not add a row directly to the generated catalogue. It would disappear on the next synchronization and would not create the implementation or runtime contribution it claims to describe.

Verify both authoring and execution artifacts
CheckPurpose
Generated catalogue contains the providerDiscovery found the contribution.
Scope names the intended capabilities/protocolsThe application explicitly admits the use.
Generated backend runtime artifact names its entrypointThe backend host has an implementation to load.
Browser host receives its frontend module mapBrowser SDK and component functions reach the bundle; provider JSON cannot carry them.
Runtime resolves the provider with its configurationThe application can actually use the declared contribution.

The catalogue is a generated authoring aid, not evidence that credentials are installed or a vendor request has succeeded. Rerun synchronization after contribution changes and retain the runtime’s validation rather than treating editor completion as deployment proof. Browser code has a separate prerequisite: supply the frontend module map through the host registration path, such as startApplicationService.initializeApplication({ frontendProviderModules, ... }) for the SaaS app. The current synchronization workflow does not generate that frontend code map.

Choose services with a clear integration contract

Bring external services into your application with named operations, explicit credentials and configuration for the runtime that uses them. Wildo includes engine provider modules and installable vendor modules that become your own code, with room for integrations you write yourself.

Choose the service for the work it must perform. Its module makes the connection requirements visible; your application supplies the account, configuration and business behavior.

An application selects an email service and a chat integration, each with its own operations and account configuration.

From a vendor name to a working integration

Find the operation you need

Select an engine service provider or call a vendor API directly. Operations describe the request the application can make, beyond simply listing a supported brand.

Configure the right place

Declare providers separately for servers and browser surfaces. Server credentials and browser public settings have different homes and consumers.

Keep requirements visible

Modules can describe optional SDKs, credential requirements and deployment options. Your application records its choices alongside the integration.

Example: Send a notification where the team works

An application selects its email provider for transactional messages and declares a task-management provider so people can turn work into tasks in their own accounts. Each has its own account and operations. The application decides when to notify; the provider contract describes how to reach the service.

For engineers

A provider declaration belongs to a runtime scope. An engine capability selection and a direct vendor operation solve different problems: one supplies an engine service, while the other addresses an API by provider and operation reference.

These selected entries come from Wonder Todos’ backend scope in wildo.saas.config.ts, with Resend’s inbound-email capability omitted:

resend: {
  engineCapabilities: [EngineCapability.EMAIL_TRANSACTIONAL],
  providerCapabilities: ['EMAIL_TRANSACTIONAL'],
  protocols: ['EMAIL_PROVIDER'],
},
asana: { engineCapabilities: [], providerCapabilities: [], protocols: ['REST_API'] },

Resend implements the transactional-email contract. Asana, a module the application installed from the module registry and now owns, declares a REST interface without claiming an engine capability; the application invokes its named operations explicitly. Empty capability lists do not mean an empty set of operations. Nor does declaring either entry demonstrate a successful exchange with the vendor.

The Asana application call demonstrates the next step: pass the current execution context, choose an account, send inputs and check the vendor’s business result. The browser activation example instead connects public contribution data to executable startup code. These are different consumers of a provider declaration.

Put each responsibility in its owning layer

LayerWhat belongs thereWhat consumes it
Provider moduleIdentity, protocols, operations and credential requirementsDiscovery and runtime composition
Application scopeProviders needed by this backend, worker or frontend surfaceConfiguration synchronization and runtime loading
Capability selectionProvider chosen for a supported engine serviceThat service’s resolver
Server credential materialDeployment secrets or credentials owned by the application, an organization or a personThe relevant backend resolver and executor
Browser contributionPublic configuration plus a browser-safe moduleHydration and SDK activation
Application behaviorWhen to call, what inputs to send and how to handle the resultThe application’s operation or workflow

Install a packaged provider in the runtime owner and run wildo config sync after changing its declarations. Engine modules use their registered loaders; a vendor module installed with wildo registry add is the application’s own package, wired into the runtimes that use it. Required optional SDKs remain application dependencies: the backend startup diagnostic reports missing declared packages but does not install them.

Browser modules use a data channel for public settings and a code channel for activation functions. Hydration joins those channels and checks their agreement. Configuration synchronization writes both: the served data, and a code map for each frontend service that the application passes to its startup. A backend module is not interchangeable with its browser facet, even when they share a vendor reference.

Extend the operation, preserve the contract

An application-owned provider uses the same contribution path, and an installed vendor module is application-owned: a missing API operation is added to its operation table in the application, and a later module update keeps the files you changed. Its input schema, HTTP request and authentication still need to describe the actual vendor operation.

Review the operation you intend to use, the account permissions it requires and the executor path that reaches it. Provider metadata can describe deployment options and credential requirements; it does not establish account readiness or prove a live vendor exchange. Reserved protocol names are not executable operations.

Find the right integration

Choose server integrations for the work you need Mechanism

Use provider modules for server-side services such as email, billing, models and business APIs. Each module describes its identity, capabilities, protocols and credential requirements.

The engine ships server modules for email (Resend, which also receives email, SendGrid, Brevo, Postmark, Mailgun and Mailchimp Transactional, plus a local preview that sends nothing), payments (Stripe) and text messages (Twilio). For AI it ships language models from Anthropic, OpenAI, Google, Mistral, Grok and Perplexity; OpenAI also produces images and embeddings, Mistral also reads scanned documents, and Anthropic, OpenAI and OpenCode run coding agents.

It also ships sign-in with Apple, GitHub, Google and Microsoft, connections to a person’s Google Drive or OneDrive, PostHog for model-call traces, Better Stack for telemetry, immudb as a tamper-evident audit ledger, reCAPTCHA to verify form submissions and Google Places for address lookup. Vendor modules from Wildo’s module registry, such as Asana, Microsoft Teams and Azure Document Intelligence, are installed into the application and become its own code.

Select the providers each runtime uses. Engine service providers and vendor-specific API modules share the provider model while serving different application needs.

Example: Give the backend an email service

A backend declares an email provider for transactional delivery. A separate worker declares only the providers it needs, so it does not load the backend’s whole vendor set.

A backend uses email and billing providers while a worker uses email only.
For engineers

Wonder CRM names its transactional email provider inside providers.scopes.backend.providers:

Selected from wildo.saas.config.ts; surrounding declarations and imports are omitted.

resend: {
  engineCapabilities: [EngineCapability.EMAIL_TRANSACTIONAL],
  providerCapabilities: ['EMAIL_TRANSACTIONAL'],
  protocols: ['EMAIL_PROVIDER'],
},

This is a selected configuration entry. The provider’s capability and protocol declarations must agree with the module. The application also enables and selects the relevant engine capability where that service uses selection, supplies credentials, and runs wildo config sync to produce runtime artifacts.

An engine-shipped provider needs no separate vendor package. Any other provider is a package installed in the runtime owner that exposes the discovery/runtime entrypoints. A vendor module from the module registry is installed with wildo registry add wildo/providers/<ref>, which copies it into the application as its own @<your-scope>/provider-<ref> package and adds that package to the backend; an application can also author one in the same shape.

Know what each engine module is for

The provider reference is what an application declares; the right-hand column is how the engine reaches it.

NeedEngine provider referencesHow it is reached
Transactional emailresend, sendgrid, brevo, postmark, mailgun, mailchimp; email-preview for local developmentEMAIL_TRANSACTIONAL slot
Marketing emailsendgrid, brevoEMAIL_MARKETING slot
Receiving emailresendEMAIL_INBOUND slot
BillingstripeBILLING slot
Text messagestwilioSMS slot
Language modelsanthropic, openai, google, mistral, grok, perplexityAI_LLM slot
Images, embeddingsopenaiAI_IMAGE_GENERATION, AI_EMBEDDINGS slots
Scanned documentsmistralAI_DOCUMENT_EXTRACTION slot
Coding agentsanthropic, openai, opencodeAI_CODING_AGENT slot
Sign-inapple, github, google, microsoftThe authentication subsystem
Personal drivesgoogle-drive, microsoft-onedriveFile pickers and file storage
Model-call traces, telemetry, audit ledgerposthog, betterstack, immudbLLM_OBSERVABILITY, OBSERVABILITY_TELEMETRY, COMPLIANCE_AUDIT_TRAIL slots
Form verification, address lookuprecaptcha, google-placesThe engine’s contact form; the address field

microsoft also carries the Microsoft Graph connection that registry modules such as Teams add operations to. Browser-side modules, such as error monitoring and file pickers, are described in browser integrations.

Load only the declared modules

Engine backend loaders map provider references to dynamic imports. Runtime composition loads the chosen set and checks protocol-key/runtime-kind agreement. Capabilities that fill an engine slot participate in its resolver; a vendor REST provider can instead be called explicitly by reference without claiming an engine capability.

ChoiceWhat it establishes
Runtime scopeWhich host may load this provider
Capability declarationWhich service contract it can satisfy
Protocol bindingWhich executable interface it implements
Credential configurationWhich account the call uses
Application call or engine selectionHow the provider is actually reached

Adding a vendor to a configuration is not a successful vendor exchange. Verify the required operation and account permissions in the target environment. Where an alternative implements the same engine contract it can be selected; a vendor-specific API remains its own contract and may require application changes.

For a vendor-specific action, the Asana application example connects this declaration to executor.execute: it supplies the current operation context, names the account target, validates the vendor reply and retains the created task’s reference.

Connect browser services without exposing server secrets Mechanism

Browser providers carry only the public settings and code a page needs, never a server secret. Wildo’s browser modules cover error monitoring, analytics, spam protection on forms, file picking from Google Drive, OneDrive or Dropbox, and address lookup, each turned on per surface.

Their declared network origins contribute to the surface’s content security policy. Their activation code remains separate from the data sent by the server.

Sign-in and payment have no browser module: signing in with Google, Microsoft, GitHub or Apple, and paying through Stripe, are redirects to the vendor, so no vendor code runs in your page; the page only shows the vendor’s name from the server’s configuration.

Example: Enable monitoring on one surface

A website selects an error-monitoring provider and supplies its public endpoint. That endpoint informs both SDK activation and the policy allowing the browser to connect to it.

Public settings and a browser module combine to activate a service; server credentials remain separate.
For engineers

Frontend providers travel through two channels. Served entries contain identity, public configuration and descriptors; a code map generated for each frontend service supplies the actual module functions. hydrateFrontendProviderModules joins them by provider identity. Data without matching code is diagnosed; code for an unselected provider stays inactive.

The application declares a provider on the intended frontend service and supplies its public configuration. wildo config sync then writes both channels: the served data, and the code map the service passes to its startup. A registry provider module brings its browser SDK as its own dependency. Selecting a provider does not supply a real account configuration.

Supply public data and browser code at their separate homes

Wonder CRM configures its product to report browser errors to a self-hosted Sentry. It selects sentry for its app frontend service with FRONTEND_ERROR_MONITORING and FRONTEND_SDK, and supplies the public DSN per environment, in the environment’s infrastructure file rather than in the provider selection block:

The block takes this shape in wildo.infra.<env>.config.ts, with the DSN the operator’s Sentry issues. Wonder CRM ships none: until an operator supplies one, wildo config sync and wildo local doctor report Sentry as declared but not configured, naming the missing dsn, and the browser does not start it.

providerConfigurations: {
  sentry: {
    [ExternalProvider_ExchangeProtocol_Kind.FRONTEND_SDK]: {
      dsn: 'https://<public-key>@<your-sentry-host>/<project-id>',
    },
  },
},

ExternalProvider_ExchangeProtocol_Kind comes from @wildo-ai/external-connectors-models. A DSN is public by construction, which is why it sits in the public FRONTEND_SDK configuration rather than in a secret. Synchronization overlays this setting onto the contribution’s public configuration while retaining its SDK and CSP descriptors. It rejects keys outside a declared public-key contract and keys identified as secrets by the same provider’s server contributions.

That is the served data. The executable half is generated by the same synchronization: a code map in src/generated/frontend-providers.generated.ts for each frontend service, importing every provider the service declares from the public entrypoint for its surface:

Selected from frontend-providers.generated.ts; the generated selectProvider helper is omitted.

// Generated by wildo config sync. Browser CODE only; environment DATA arrives separately.
import type { GeneratedAppProviderModules } from '@wildo-ai/external-connectors-public/app';
import * as providerPackage0 from "@wonder-crm/provider-sentry-frontend/app";

// …
export const generatedFrontendProviderModules: GeneratedAppProviderModules = {
  ["sentry"]: selectProvider(providerPackage0, "sentry"),
};

The product passes generatedFrontendProviderModules as frontendProviderModules in its startup options; a website’s page bridges pass their own service’s map to createWebsitePageBridgeIsland. Hydration joins this code with the served Sentry entry, and activation reads the DSN. Each surface’s map imports only that surface’s facet, so a product module never lands in a website bundle. Do not edit the generated file: rerun synchronization after changing a declaration. The module package depends on @sentry/browser itself, so the import the code map makes brings the SDK with it. See runtime separation.

Derive the network policy from the same setting

The Sentry website module declares where to find the connection origin and how to activate its SDK:

Selected from website.ts; surrounding declarations and imports are omitted.

csp: {
  derivedOrigins: [
    { publicConfigKey: 'dsn', bucket: ProviderFrontendCspOriginBucket.CONNECT },
  ],
},
sdk: {
  descriptor: { packageName: '@sentry/browser' },
  async activate({ providerRef, publicConfig }) {
    const dsn = publicConfig.dsn;
    if (typeof dsn !== 'string' || dsn.length === 0) {
      throw new Error(
        `Provider "${providerRef}" needs a Sentry DSN in its publicConfig.dsn. Without one the SDK starts `
        + 'and reports nothing, which is indistinguishable from a healthy site. Supply it under '
        + "providerConfigurations.sentry.frontend_sdk in the application's wildo.saas.config.ts.",
      );
    }

    // DYNAMIC on purpose: `@sentry/browser` is this facet's own dependency (the module pins it, #2787), and a dynamic
    // import makes it its own chunk, so the page pays for it only once the provider activates. A browser entry's eager
    // closure may not reach it (publish refuses that).
    const Sentry = await import('@sentry/browser');
    const client = Sentry.init({ dsn });

    return {
      handle: client,
      deactivate: async () => {
        await client?.close();
      },
    };
  },
},

This activation validates a nonempty DSN, dynamically imports the SDK and returns a handle plus deactivation callback. The DSN here is public client configuration, not a backend API secret. Its origin is derived rather than hardcoded into a separate application list.

Hydration compares descriptors and CSP declarations so stale generated code/configuration does not silently masquerade as agreement. Activation failures name the provider and are isolated from other provider activations.

Use browser-safe entrypoints for the appropriate surface. The backend facet may share the vendor identity, but its secret-aware module does not belong in the browser bundle. Public configuration is visible to visitors by design; never use it to transport tenant or deployment credentials.

Use vendor API definitions without another integration runtime Mechanism

A provider module describes a vendor’s API operations as data: request methods, paths, inputs and authentication. You install it from Wildo’s module registry into your application, where it becomes your own code, and Wildo’s executor calls the vendor from those definitions.

The registry offers three modules. Asana creates a task and lists the workspaces and projects a person’s connected account can see. Microsoft Teams lists teams, channels and chats, and reads, replies to, updates, reacts to and deletes messages. Azure Document Intelligence reads scanned documents for the application’s search and extraction. Each module also brings a skill that teaches the coding agent working in your repository how to use it.

Teams adds its operations to the Microsoft connection the engine already owns, so a person connects their Microsoft account once and every Microsoft operation the application opts into spends that one grant. For a vendor the registry does not offer, author a module in your application with the same shape.

Example: Create a task in a person’s own Asana account

The Asana module declares a task-creation operation and the listings its inputs are picked from. The application calls that operation through the provider executor, spending the account the person connected.

API definitions and authored additions become operations executed as vendor requests.
For engineers

wildo registry add wildo/providers/asana copies the module into providers/asana/ and renames its package to your application’s scope, @<your-scope>/provider-asana. It adds the package to your workspace and to every backend service, and writes a scope entry for the ref with empty capability and protocol lists: the install cannot know what the vendor does for you. Wonder Todos completes that entry with engineCapabilities: [], providerCapabilities: [] and protocols: ['REST_API']. That is intentional: the application calls this REST provider directly rather than selecting it for an engine service slot. Supply its credentials and synchronize its runtime artifacts.

The copy is yours. Edit it where it is installed; wildo registry update keeps a file you changed and says so, rather than overwriting it. The module’s skill is installed beside the framework’s own skills when the skills are synchronized, and removed with the module.

Opt in a module that extends an engine provider

Teams is an augmentation: it declares no provider reference of its own, so the install writes no scope entry and tells you the one line it still needs. Wonder Todos declares the engine’s microsoft provider for its REST interface and lists the installed package under it:

Selected from wildo.saas.config.ts; the explanatory comments and the rest of the providers block are omitted.

microsoft: { engineCapabilities: [], providerCapabilities: [], protocols: ['REST_API'] },

// …
augmentations: {
  microsoft: ['@wonder-todos/provider-microsoft-teams'],
},

Until it is listed there, the installed package contributes nothing. Once listed, its operations run on the engine provider’s Microsoft Graph connection and registration; the connection’s client ID and secret are the operator’s to supply, and each operation asks for its own Graph permission the first time it is used.

Azure Document Intelligence is the third shape: an extension that fills the engine’s document text extraction slot. An application selects it as that capability’s primary provider rather than calling it by operation. See scanned document extraction.

Call with an account and inspect the vendor result

This illustrative application-service body uses an injected ProviderOperationExecutorBackendService as executor and the current operation’s executionContext. userId, workspaceGid and taskName are application inputs. Asana must already be declared in this backend, and the person must have connected their Asana account. Import z from zod and the connection enums from @wildo-ai/external-connectors-models.

const response = await executor.execute(
  {
    providerRef: 'asana',
    operationId: 'create_task',
    connection: {
      ownerScope: ExternalProvider_Connection_OwnerScope.USER,
      ownerId: userId,
      delegation: ExternalProvider_Connection_DelegationMode.SERVICE,
    },
    input: { name: taskName, workspace: workspaceGid },
  },
  executionContext,
);

if (response.status < 200 || response.status >= 300) {
  throw new Error(`Asana did not create the task: HTTP ${response.status}`);
}
const task = z.looseObject({ gid: z.string().min(1), name: z.string().optional() }).parse(response.output);

const createdTask = { gid: task.gid };

The target names the person’s own account, which only that person may spend. Omitting connection would spend the application’s own account instead; to spend an organization’s, supply its authorized connection target. The execution context carries the caller’s authority; a provider reference alone does not choose an account.

An HTTP failure comes back as a status rather than an exception, so the caller checks it. The operation reads Asana’s answer at its data field, so output is the created task; this operation declares no output schema, so the example validates the task before recording its reference. A timeout is not proof that no task was created: do not blindly repeat a write that may already have succeeded.

For a typed convenience client, the module exports createAsanaClient: createAsanaClient(executor.forExecution(executionContext)) binds the same context, and each method takes the connection as its last argument. Its createTask method returns the vendor payload, without the status envelope; apply the same checks. Use executor.execute when the application needs both status and output.

Correct the definitions you run

Asana’s module was seeded from an open integration catalogue and then corrected by hand, because the imported task creation could not create a task:

Selected from operations.ts; the entry’s inputOptions, surrounding declarations and imports are omitted.

create_task: {
  operationId: 'create_task',
  domain: 'default',
  method: API_HttpMethod.POST,
  pathTemplate: '/api/1.0/tasks',
  classification: ExternalOperation_Classification.WRITE,
  summary: 'Creates a task in a workspace, or in one or more projects.',
  requestBody: { encoding: ProviderRestRequestBodyEncoding.JSON, envelope: 'data' },
  inputSchema: CreateTaskInputSchema,
  // Asana answers `{ data: <task> }` as well; the payload a caller relies on is the task itself.
  resultPath: 'data',
},

The imported operation sent its fields without the data envelope Asana reads, named the wrong field for projects, and lost the rule that a task must be placed in a workspace, in projects or under a parent task. The adopted module declares the envelope on the request body and refuses a call that places the task nowhere before anything is sent. The same entry names the listings its workspace and projects are picked from, which is what lets a person choose them from their own account.

A module enters the registry by being adopted and corrected by hand; nothing regenerates the registry from other catalogues. After installation it is maintained as your code. A module is software to review like any other dependency.

Describe each sign-in provider once Mechanism

Sign-in vendors differ in endpoints, requested scopes and identity fields. Provider contracts describe those differences so the authentication flow can use them without scattering vendor-specific branches through the application.

Configure the application’s sign-in options or a customer’s identity connection separately. A provider module makes the integration available; it does not enable login by itself.

Example: Connect a familiar sign-in identity

A configured provider exchanges its authorization response and maps the returned identity into the application’s user fields. The application still decides which sign-in methods and user types are allowed.

A configured identity provider returns verified fields to application sign-in.
For engineers

Google’s backend module declares its OAuth/OIDC transport shape:

Selected from google.backend.module.ts; surrounding declarations and imports are omitted.

kind: ProviderProtocolRuntimeKind.OAUTH2_SSO,
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
tokenEndpoint: "https://oauth2.googleapis.com/token",
userInfoEndpoint: "https://www.googleapis.com/oauth2/v2/userinfo",
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
issuer: "https://accounts.google.com",
scopes: [
  "openid",
  "email",
  "profile"
],
clientIdPublicConfigKey: "GOOGLE_OAUTH2_CLIENT_ID",
clientSecretRef: "GOOGLE_OAUTH2_CLIENT_SECRET",
supportsPkce: true,
supportsOidc: true,
tokenEndpointAuthMethod: "client_secret_post",

The contract also maps the vendor profile into identifier, email, name and verified-email fields. It describes protocol behavior; the application supplies its client ID and secret through provider configuration and secret resolution.

Configure the actual login path

This illustrative Google setup supplies two separate backend configuration sections. Merge them into the application’s existing configuration; retain its other auth settings and providers. googleClientId is the client ID issued for your application. Import OAuth2_Purpose and ExternalProvider_ExchangeProtocol_Kind from @wildo-ai/external-connectors-models.

const socialLogin = {
  auth: {
    socialOAuth2Providers: {
      google: {
        provider: 'google',
        enabled: true,
        purpose: OAuth2_Purpose.USER_SSO,
      },
    },
  },
  providerConfigurations: {
    google: {
      [ExternalProvider_ExchangeProtocol_Kind.SSO_OAUTH2]: {
        clientId: googleClientId,
      },
    },
  },
};

socialOAuth2Providers admits the provider for login. providerConfigurations.google.sso_oauth2 supplies the application account’s client ID. The Google contract resolves its secret from GOOGLE_OAUTH2_CLIENT_SECRET; set that through the deployment’s server secret material, never in the browser-facing configuration. Both the client ID and baseline secret are required before any scope-level credential override is applied.

In wildo.saas.config.ts, declare the provider inside providers.scopes.backend.providers. Wonder Todos uses Google both for a language model and for sign-in, so its entry carries both; the sign-in half is SSO_OAUTH2 in the capability and protocol lists:

Selected from wildo.saas.config.ts; an explanatory comment is omitted.

google: {
  engineCapabilities: [EngineCapability.AI_LLM],
  providerCapabilities: ['LLM_CHAT', 'SSO_OAUTH2'],
  protocols: ['LLM_PROVIDER', 'SSO_OAUTH2'],
},

An application using Google only for sign-in declares SSO_OAUTH2 alone. The backend refuses a social sign-in whose provider scope does not enable this protocol.

Run wildo config sync to synchronize the provider artifacts. This engine-shipped provider needs no extra vendor package. The backend provider catalogue explains runtime registration. A provider installed in another runtime is not thereby enabled on the login backend. No Google code runs in the page: sign-in is a redirect to the vendor, and the button’s name comes from the configured provider reference.

Allow the method for the intended people

In the intended userTypes[ref].auth policy, add the method while preserving the existing policy:

auth: {
  authMethodsEnabled: {
    ...existingAuthMethods,
    [AuthMethod.EXTERNAL_OAUTH2]: true,
  },
  // Keep the user type’s existing registration, MFA and other policies.
},

Import AuthMethod from @wildo-ai/saas-models. This fragment illustrates the method change, not a complete user-type definition. New-account creation is a separate decision: if public social registration is intended, the target user type’s registration.mode must be RegistrationMode.OPEN and registration.allowedMethods must include AuthMethod.EXTERNAL_OAUTH2 (RegistrationMode is also exported by @wildo-ai/saas-models). The initiating frontend must map to that same user type through frontendServices[serviceName].usersManagement, with auth: true for sign-in and register: true for new-account creation. An unrelated user type with open registration cannot authorize this flow. Enabling login alone does not grant open registration or bypass verified-email linking rules.

Wonder Todos shows the corresponding frontend-to-user-type mapping in its backend configuration. Adapt the service and user-type references to your application; use register: false when only existing accounts should sign in. This mapping alone does not enable social login.

frontendServices: {
  'wonder-todos-app': {
    usersManagement: {
      member: { register: true, auth: true, isDefault: true },
      admin: { register: false, auth: true },
    },
  },
},
Register the callback that this deployment exposes

The resolver constructs the redirect URI from runtime.endPoints.main_backend_api.publicUrl followed by /api/v1/auth/oauth2/callback/google. Register that exact URI with the vendor. The origin is the public backend address, not the frontend’s address. Missing public URL, client ID or secret stops connection resolution before a redirect is usable.

Use the standard authentication flow to start login and consume its state/callback. Do not create an account from a profile supplied directly by the browser. The resolver example proves configuration assembly; a successful external sign-in still requires the vendor account, callback registration and the application’s account policy to agree.

Enterprise OIDC can use an application-scoped connection for the application’s sign-in or an organization-scoped connection for a customer’s sign-in. Each keeps its own configuration and access policy. SAML sign-in is served by the authentication subsystem, not by pretending the reserved provider-side SAML capability is a shipped vendor module.

Use the same vendor reference consistently across the configured surfaces. The authentication subsystem resolves sign-in contracts directly; these capabilities do not require inventing an engine service-selection slot.

Decide how to handle missing provider assurance

Social sign-in and enterprise delegation have different trust contracts. Provider definitions record the evidence supported by Google, Microsoft, GitHub and Apple, together with prerequisites and documentation. Account-level two-factor enrollment is not proof of MFA in the current login.

Set userTypes.<type>.auth.mfaPolicy.externalAssuranceEnforcement in backend configuration using ExternalAssuranceEnforcement from @wildo-ai/saas-models. The default records a warning when required social MFA cannot be established; STRICT refuses session issuance. Organization policy can tighten this setting. Invalid tokens and failed explicit authentication-context demands remain refusals in either mode.

Enterprise OIDC connections delegate MFA enforcement to the customer’s identity provider. Application terms should explain that responsibility and Wildo’s retained validation and access checks. Provider brand alone does not establish enterprise trust.

Prepare its configuration

Give each integration the credentials it needs Mechanism

Each provider describes the credentials it needs without putting their values in the module. Runtime configuration supplies the actual material to the processes that use it.

The distinction matters when a vendor supports several services or a customer connects its own account: the right provider is not enough; the call must also use the right identity.

Example: Keep model access separate from sign-in

One vendor can need an API key for model requests and a different client secret for sign-in. Both requirements are named, so an operator can supply the appropriate material without confusing the two.

A vendor has distinct model and sign-in credential requirements, separate from public settings.
For engineers

The Google backend module names independent secrets for its model and sign-in protocols:

Selected from google.backend.module.ts; surrounding declarations and imports are omitted.

secretsContract: {
  apiKey: {
    envVarName: "GOOGLE_API_KEY",
    required: true,
    description: "Google API key for Gemini LLM calls."
  },
  oauth2ClientSecret: {
    envVarName: "GOOGLE_OAUTH2_CLIENT_SECRET",
    required: true,
    description: "Google OAuth2 client secret for SSO token exchange."
  }
},

The keys describe the requirement; envVarName connects it to deployment material, and the description explains its purpose. Protocol contracts refer to the appropriate secret. The module is registered on the backend side, and the application declares it in each runtime scope that should use it.

wildo config sync materializes scoped runtime environments from the provider declarations. Application runtimes receive provider material for their declared set; the platform tier has an explicit broader entitlement. An empty declared set differs from an omitted scope. This contract governs generated environments, not a claim that a process can never receive an extra variable from its operator.

Resolve the credential for the account the call names

The provider executor resolves access through an explicit connection target. Its owner scope determines which account supplies the credential:

Connection ownerCredential sourceIf missing or unreadable
ApplicationThe deployment’s provider credentialRefuse the call
OrganizationThat organization’s connected credentialRefuse; do not substitute the deployment account
UserThat person’s connected credentialRefuse; do not substitute another account

resolveProviderSecretForTarget implements these choices and is consumed by the shared HTTP API access resolver. Authorization of the target is a separate check: naming an owner does not grant permission to spend that owner’s credential. Let the sanctioned executor resolve access from the admitted target rather than looking up a secret in application code.

An encrypted credential that cannot be opened is an error, not evidence that another identity should be tried. This keeps the remote action attributable to the requested account. Some older framework consumers use a separate organization-first resolver with deployment fallback; that legacy behavior is not the target-aware executor’s contract.

Browser modules carry public configuration and receive no backend secret contract.

A required flag is information used by the relevant provider builder, not a universal startup validation guarantee. Optional credentials still need validation by the provider that consumes them. Never put a real secret value in the module, public contribution or example code.

Make optional SDK requirements visible Mechanism

A provider can name the extra library it loads when used. The backend checks these declared runtime packages and reports missing ones before the capability is first needed.

Install the SDK in the runtime that enables that provider. Declaring a provider does not install its optional dependency.

Example: Prepare an observability sink

A backend selects an observability provider that uses an optional analytics SDK. Its startup diagnostic names the missing package so the operator can install it in that backend.

A provider depends on an SDK being available in its runtime.
For engineers

The PostHog backend provider, a registry module, puts its runtime package next to the factory that uses it:

Selected from backend.exports.ts; surrounding declarations and imports are omitted.

  subjectErasureEffect: posthogSubjectErasureEffect,
  requiredPackages: ['posthog-node'],
  agentCallTraceSinkFactory: (config, logger) =>
    new PostHogAgentCallTraceSink(
      {
        projectApiKey: config.apiKey,
        // PostHog-specific connection detail — owned by the provider, NOT the engine.
        host: config.secrets.host,
        captureContent: config.captureContent,
        sampleRate: config.sampleRate,
      },
      logger,
    ),
};

This is a module fragment, not application configuration. The module’s package depends on posthog-node, so installing it (wildo registry add wildo/providers/posthog) brings the SDK; the declaration lets startup report it when it cannot be resolved where the provider runs. The ingest host is a declared slot of the module, resolved by the engine and passed in config.secrets. The sink lazy-loads the SDK; the engine itself never depends on it. immudb, which ships with the engine, declares immudb-node the same way, as an optional peer an application installs.

Understand the startup check

ExternalProvidersRegistryBackendService.reportMissingProviderRuntimePeers iterates the composed modules’ requiredPackages. It tries resolution from the running application first and the engine module second, avoiding a false missing-package report when the SDK is installed only in the application.

The diagnostic is a warning, not installation or a process-wide refusal. A provider may be declared but never reached in an environment. When the sink is actually constructed, its lazy import and provider-specific failure handling determine whether it is available.

This field is not the complete dependency graph. Some protocols carry an SDK descriptor on their runtime contract, and browser activation has its own optional-peer boundary. Check the actual provider’s requirement and its consumer rather than assuming every dependency appears here.

The declaration therefore has two useful roles: it documents what the provider loads and supplies an actionable startup diagnostic. It does not prove the installed package’s version is compatible or that a real vendor request succeeds.

Keep vendor facts beside the integration Mechanism

A provider can describe its deployment options and processing regions. The application records which option it actually uses, so tooling can compare the configured choice with the provider’s declared facts.

This supports an inspectable account of external services. It does not turn a vendor description into proof of the application’s actual data flows.

Example: Distinguish hosted and self-hosted use

The same integration may support a vendor-hosted service or an operator-run deployment. The application records its choice explicitly rather than treating every selected provider as an external recipient.

A self-hosted deployment choice is carried into the provider report.
For engineers

ProviderComplianceProfile carries deployment models, vendor-region labels and an optional legal identity. ApplicationProviderComplianceChoice carries the operator’s selection per configured provider. These are different facts: a vendor offering a region does not establish that the application selected it.

Wonder CRM records the self-hosted monitoring choice in its application configuration:

Selected from wildo.saas.config.ts; surrounding declarations and imports are omitted.

sentry: {
  deploymentModel: ProviderDeploymentModel.SELF_HOSTED,
},

This selected entry belongs under providerComplianceChoices. The schema requires a region for a vendor-hosted choice and disallows a provider-processing-region field on a self-hosted choice. The operator’s own hosting description covers the latter; the provider-region field is not a general infrastructure locator.

Report disagreement instead of inventing agreement

The comparison helper makes a configured region outside the provider’s declared list visible:

Selected from provider-compliance-profile.shared.schemas.ts; surrounding declarations and imports are omitted.

for (const [providerRef, choice] of Object.entries(params.choices)) {
  if (choice.processingRegion === undefined) continue;
  const profile = params.profilesByProviderRef[providerRef];
  // No profile is not an assertion about a region — it is a provider that declares no compliance
  // profile at all, which is a different gap and belongs to whoever reports THAT.
  if (profile === undefined) continue;
  if (profile.processingRegions.includes(choice.processingRegion)) continue;
  asserted.push({ providerRef, region: choice.processingRegion });
}

return asserted;

The companion’s compliance-primary-facts report consumes that result using profiles carried by provider specifications. An unlisted choice is reported as application-asserted, not refused automatically: it may be a typo or a provider list that needs updating.

A profile is generally optional, but backend composition requires it for a coding-agent provider. Where information is missing, keep that absence explicit. The profile is evidence about declared provider options, not a certificate, a transfer determination or proof that every outbound path has been identified.

Reconcile the configured services with the application’s actual operations and its processor information. Direct outbound requests, webhooks and application-owned integrations can carry data too; this metadata alone does not enumerate or constrain every egress path.

Extend the catalogue deliberately

Add your own integration through the same model Mechanism

An application can supply a provider of its own instead of waiting for a vendor to join the engine. Its module declares what it offers and which runtime can use it.

Use the same discovery and configuration path as shipped providers, while keeping backend secrets and browser-safe behavior in their appropriate packages. A vendor module installed from Wildo’s module registry is application-owned in the same way: once installed, it is your package.

Example: Share upload defaults across the application

An application-owned file-picker provider declares accepted file types and size limits. Standard file fields can use those defaults while retaining stricter field-specific requirements.

An application-owned provider supplies shared defaults while individual fields retain their own rules.
For engineers

Wonder Todos’ application-owned frontend module is a real configuration provider:

Selected from file-picker.frontend-app.module.ts; surrounding declarations and imports are omitted.

export const ApplicationFilePickerFrontendAppProvider: FrontendAppProviderModule = {
  // Identity (#328): an application-owned provider is APPLICATION-origin; a file picker binds no
  // engine slot, so it is CATALOGUE (EXTENSION is for a substitute that fills an engine slot).
  metadata: defineProviderMetadata({
    ref: 'wonderTodosFilePicker',
    packageName: '@wonder-todos/main-app',
    tier: ProviderTier.CATALOGUE,
    origin: { kind: ProviderOriginKind.APPLICATION },
  }),
  providerCapabilities: [BUILTIN_PROVIDER_CAPABILITY.FRONTEND_FILE_PICKER],
  protocols: [BUILTIN_PROVIDER_PROTOCOL.FRONTEND_SDK],
  publicConfig: {
    acceptedMimeTypes: ['image/*', 'application/pdf'],
    maxFileSizeMb: 25,
    maxSelectionCount: 10,
    uploadNamespace: 'task-attachments',
  },
};

APPLICATION records its origin. CATALOGUE is appropriate because this provider does not fill an engine service slot; an extension that does fill one has different tier semantics. The metadata does not supply a picker renderer: this example carries constraints consumed by standard file controls.

Make the module discoverable and reachable

The frontend contribution names a runtime import path, permitted frontend target and values-free entry derived from the module. Export the runtime module from the package entrypoint, declare its reference under the app frontend service’s providers, and run configuration synchronization. The served data and the browser code must both be present for function-valued provider code to execute. Synchronization writes both: the data, and a generated code map, src/generated/frontend-providers.generated.ts in each frontend service, which imports every declared provider from its runtime entrypoint. Wonder Todos passes that map to its startup as frontendProviderModules, and its file picker reaches the browser this way.

Backend-owned integrations instead expose a backend module and companion contribution through their package, declare their protocol runtime contract and secrets requirements, and install that package in each relevant runtime. The framework composition validates identity/tier/protocol coherence; those checks do not implement the vendor API for you.

A custom browser component that returns remote files also needs its declared delivery contract and the sanctioned host/checking path. Public configuration defaults are not server upload enforcement, and a custom direct mount owns the checks it bypasses. Keep the provider’s responsibility narrow and document which standard consumer actually reads each setting.

Distinguish available integrations from reserved names Planned Mechanism

Planned — not available yet.

A name in a capability or protocol vocabulary is not always an executable integration. Reserved entries make that distinction explicit while the available modules describe what can actually be used.

Check for a provider implementation and its runtime contract before planning around a named integration type.

Example: Read a protocol name accurately

Audio and video generation have reserved protocol names. Their presence in the vocabulary does not supply a callable backend provider or a delivery date.

An available contract has an implementation; a reserved name remains a placeholder.
For engineers

The backend runtime-kind object defines the executable protocol arms:

Selected from commons.external-connectors-models.schemas.ts; surrounding declarations and imports are omitted.

export const ProviderProtocolRuntimeKind = Object.freeze({
  LLM: ExternalProvider_ExchangeProtocol_Kind.LLM_PROVIDER,
  IMAGE: ExternalProvider_ExchangeProtocol_Kind.IMAGE_PROVIDER,
  EMBEDDINGS: ExternalProvider_ExchangeProtocol_Kind.EMBEDDINGS_PROVIDER,
  DOCUMENT_EXTRACTION: ExternalProvider_ExchangeProtocol_Kind.DOCUMENT_EXTRACTION_PROVIDER,
  AGENT_CLIENT_PROTOCOL: ExternalProvider_ExchangeProtocol_Kind.AGENT_CLIENT_PROTOCOL,
  EMAIL: ExternalProvider_ExchangeProtocol_Kind.EMAIL_PROVIDER,
  EMAIL_INBOUND: ExternalProvider_ExchangeProtocol_Kind.EMAIL_INBOUND_PROVIDER,
  OAUTH2_SSO: ExternalProvider_ExchangeProtocol_Kind.SSO_OAUTH2,
  BILLING: ExternalProvider_ExchangeProtocol_Kind.BILLING_PROVIDER,
  WEBHOOK_ORIGINATOR: ExternalProvider_ExchangeProtocol_Kind.WEBHOOK_ORIGINATOR,
  SMS: ExternalProvider_ExchangeProtocol_Kind.SMS_PROVIDER,
  REST: ExternalProvider_ExchangeProtocol_Kind.REST_API,
} as const);

Audio and video names remain in the broader exchange vocabulary but do not appear in this runtime subset. A declaration cannot obtain an executable implementation merely by selecting their names.

The provider-side SSO_SAML capability is also reserved. SAML authentication itself exists through the engine’s authentication subsystem, so the reserved provider name must not be presented as absence of SAML login.

Separate reservation from withdrawal

The former BILLING_PAYMENTS engine slot was withdrawn. Billing uses its supported mapping and runtime contract; the removed slot is not an option to enable. Document generation and scanning also have their own implementation paths rather than unfillable provider-selection entries.

What existsWhat an application may conclude
A named capabilityThe vocabulary recognizes the concept
A module claiming itThere is a provider declaration to inspect
An executable runtime contractThere is a defined invocation path
Configured credentials and a verified exchangeThe intended deployment can perform that action

When adding a provider, supply the implementation and the capability/protocol mapping that makes it reachable. Do not reintroduce a removed engine slot solely to make the catalogue appear broader. These reserved entries describe vocabulary boundaries, not a release roadmap.

Work with the ERP and CRM the company already runs

Most business applications sit next to a system that already owns the company’s customers, products, suppliers and invoices: Odoo, SAP S/4HANA, Salesforce, Microsoft Dynamics 365, Business Central, NetSuite or another OData service. Wildo treats that system as a system of record: declared once, reached with the application’s own service identity, and used under rules the application states rather than through ad hoc calls.

Wildo supplies the vendor descriptions, the protocols, the write discipline and the pipelines. The application decides, record by record, whether the data stays where it lives, is copied and kept current, is written back, or is kept in step on both sides, and which system owns each field.

An ERP or CRM declared once supports an application through a read-in-place window, a current local copy, writes back, and two-way exchange with ownership marked per side.
One declaration, four ways to use the system the company already runs.

One declaration, four ways to use it

Read it where it lives

A resource can be backed directly by the remote system: every read goes to it, and opened writes and actions such as posting an invoice go through to it, each sent once and audited.

Keep a copy that stays current

A pipeline copies the records the application needs into its own store, reads only what changed when the vendor allows it, and lets a webhook ask for the records it names to be read again.

Decide who owns each field

A pipeline can send local changes back, or keep both sides in step with the owner of each field declared and a policy for the case where both changed.

Example: Customers from the ERP, notes in the tool

A company’s account managers work in an internal tool. Customer companies are copied from the ERP every hour, with only the changes read; the tool owns its own notes and follow-ups on each customer. Products are read directly from the ERP, and a price corrected in the tool is written back to the ERP after its last-updated time is checked. Staff see on each synced field where it came from and how current it is.

For engineers

Declare the system once

A system of record is declared by name in wildo.saas.config.ts. The name is the stem of every environment variable the instance needs, so a second deployment of the same vendor is a second name. This is Wonder Todos’ Odoo declaration, comments omitted:

systemsOfRecord: {
  odoo: {
    vendor: 'odoo',
    dialect: HttpApiTransportDialect.ODOO_JSONRPC,
    partition: true,
    personalDataOrigin: SystemOfRecordPersonalDataOrigin.NO_PUBLICLY_ACCESSIBLE_SOURCES,
  },
},

partition: true lets an operator assign each Odoo company to an organization unit, so the people of that unit work with that company’s records; a copied resource must still gate its reads above the default member role for the confinement to hold. personalDataOrigin is stated here so the privacy notice can say where copied personal data came from. A system of record is not a provider: it belongs to the application’s owner organization, it is read with the application’s own service identity, and no person’s connected account is spent on it. At boot the engine joins the declaration, the vendor description and the environment, and refuses an incoherent instance without contacting the remote, so an ERP outage does not stop the application starting.

Choose the storage consequence per resource

ChoiceWhere rows liveWhat it givesWhat it costs
Read-through bindingThe system of recordAlways current; opened writes and actions go straight throughEvery read depends on the remote; nothing local to enrich
Inbound pipelineThe application’s storeLocal enrichment, search, joins; incremental passes and webhook re-readsFreshness depends on successful runs
Outbound pipelineThe application’s store, sent onThe application owns the record and the ERP receives itEach changed row is one write, with its own failure handling
Two-way pipelineBothEach field owned by the remote, the application or both, with a conflict policyNeeds a vendor that can check versions; last-writer-wins vendors are refused

Open writes deliberately

Writes are declared on the binding, never inferred. This excerpt from Wonder Todos’ billable-services resource, with its field mappings abbreviated, opens create, update and delete on Odoo’s products and states how a lost update is narrowed:

httpApiBinding: {
  dialect: HttpApiTransportDialect.ODOO_JSONRPC,
  system: 'odoo',
  entityRef: 'product.template',
  keyFields: [{ localField: 'odooProductId', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
  fieldMappings: [/* list_price, uom_name, write_date */],
  subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
  writableVerbs: [HttpApiWriteVerb.CREATE, HttpApiWriteVerb.UPDATE, HttpApiWriteVerb.DELETE],
  writeConcurrency: { mode: HttpApiWriteConcurrencyMode.VERSION_CHECKED, versionField: 'lastUpdatedAt' },
},

Each opened verb needs its operation declared too; the factory refuses one without the other. A write is admitted once under an idempotency key and recorded with its outcome. A write whose outcome cannot be known is never sent again blindly: a retry is refused, and a pipeline holds such a write for an operator to answer. Where the vendor offers an atomic version check, as OData services do, the write carries it; where it does not, the binding states the weaker stance it accepts.

Know what each vendor supports

The vendor descriptions ship in the opt-in @wildo-ai/systems-of-record library, which wildo config sync adds once a system is declared. Each states the protocols, credentials, incremental modes and write prerequisites the vendor allows, and a vendor that cannot do something refuses it at startup by name: SAP S/4HANA writes, for example, need a protection handshake the engine does not yet perform. Any OData v4 service can be declared through the generic description, and a package can extend a vendor’s description with a partition or a credential kind without changing the engine.

Each vendor is checked against a simulator faithful to its protocol, Odoo also inside a running application; none has yet run against a live tenant. Verify the instance’s exposed API and the service user’s permissions before a business workflow depends on them.

Connect the systems the company runs

Work with Odoo from your application Mechanism

A company that runs its business in Odoo can build internal tools on the records Odoo already holds. The application declares the company’s Odoo once, by name, and reaches it with its own integration key, never with one of its users’ accounts.

Read in place: a list or record screen asks Odoo when it opens, so it always shows what Odoo holds. Copy kept current: a scheduled pipeline copies chosen fields into the application, where they sit beside fields only the application owns, and it can read only the records changed since its last pass.

Write back: a resource can create, change and delete Odoo records, and an update is checked against the version the person was looking at, so most edits made over someone else’s newer change are refused instead of silently overwriting it. Actions: an Odoo operation such as posting a draft invoice can be run from the application, under the same roles, company limits and audit as a write.

Per company: when one Odoo database holds several companies, each subsidiary’s people see, change and act on their own company’s records only. Both directions: a pipeline can send records created in the application to Odoo, or keep records edited on both sides in step, with each field owned by one side or shared.

Example: Client accounts and invoices for a services firm

A project tool copies client accounts from Odoo every hour and lets the team add internal notes; Odoo stays the source of each client’s name and email. Finance managers maintain the catalogue of billable services in Odoo from the tool’s own screens, and post draft invoices from it. A manager of one subsidiary does all of this for that subsidiary’s company only.

Within one subsidiary, Odoo client accounts are copied into a project tool with local notes, while catalogue edits and invoice posting go back to Odoo.
For engineers

An Odoo deployment is a system of record, not a provider: nothing selects it through the capability plane, and no person’s connected account is spent on it. Wonder Todos declares its Odoo under systemsOfRecord in wildo.saas.config.ts, comments omitted:

systemsOfRecord: {
  odoo: {
    vendor: 'odoo',
    dialect: HttpApiTransportDialect.ODOO_JSONRPC,
    partition: true,
    personalDataOrigin: SystemOfRecordPersonalDataOrigin.NO_PUBLICLY_ACCESSIBLE_SOURCES,
  },
},

The name odoo is yours to choose. It identifies this instance, so a second deployment of the same vendor is a second name. The odoo vendor facet ships in the systems-of-record library, @wildo-ai/systems-of-record, which wildo config sync adds to the backend as soon as a system of record is declared. The facet offers one credential kind, so the declaration does not name it. partition: true turns on per-company confinement, and personalDataOrigin tells the privacy notice that the personal data in this Odoo was collected by the company itself, none of it from public sources. Three Wonder Todos resources bind the instance by name: a copied client list, a read-through service catalogue that writes back, and read-through invoices with a posting action.

Supply the address and key per environment

The facet asks for three address values, baseUrl, database and login, and one secret, apiKey. Addresses go under systemsOfRecord.odoo in the environment’s wildo.infra.<env>.config.ts; the key goes in that environment’s secrets.json. You never write a variable name: they are derived from the instance name as WILDO_SOR_ODOO__BASE_URL, WILDO_SOR_ODOO__DATABASE, WILDO_SOR_ODOO__LOGIN and WILDO_SOR_ODOO__API_KEY.

An environment with no access to the deployment, such as a developer machine, declares odoo: { enabled: false }, as Wonder Todos’ local environment does. The application still boots, and reads of bound resources are refused rather than answered as empty. Leaving the instance out of an environment’s configuration is refused, because it cannot be told apart from a forgotten address. At boot the engine joins the declaration, the vendor facet and the environment, and refuses an incoherent instance; it does not contact Odoo, so an Odoo outage does not stop the application starting.

How the client authenticates

The shared client calls Odoo’s authenticate with the configured database, login and key to obtain a user id, then puts database, user id and key in the arguments of every model call. The user id is cached against the credential it was obtained with, so a rotated key authenticates again without an invalidation step. The configured Odoo user decides what the application can reach: it must have access to the models and methods the resources use.

Copy client accounts

The following is the binding and mapping portion of Wonder Todos’ externalCustomers pipeline, with comments omitted. Its resource schema, operations and registration surround this excerpt. SystemOfRecordSubjectRightsPosture and HttpApiTransportDialect are imported from @wildo-ai/saas-models; the other enums come from @wildo-ai/saas-models/external-data.

externalDataPipeline: {
  binding: {
    dialect: HttpApiTransportDialect.ODOO_JSONRPC,
    system: 'odoo',
    entityRef: 'res.partner',
    keyFields: [{ localField: 'id', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
    subjectRights: { posture: SystemOfRecordSubjectRightsPosture.OWNED_COPY },
    purposeRefs: ['task-planning-service'],
  },
  remoteKeyLocalField: 'odooPartnerRef',
  partitionUnitLocalField: 'organizationUnitId',
  partitionKeyLocalField: 'odooCompanyRef',
  mapping: [
    {
      kind: ExternalDataMappingEntryKind.CONCATENATION,
      localField: 'name',
      parts: [
        { kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'name' },
        { kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'ref' },
      ],
      separator: ' · ',
    },
    { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'email', localField: 'email' },
  ],
  sourceFilter: { is_company: true },
  population: ExternalDataDestinationPopulation.CLOSED,
  orphanPolicy: ExternalDataOrphanPolicy.IMPERSONALIZE,
  scheduleCron: '0 * * * *',
},

The binding declares no tenancy. Rows read out of a system of record are stamped with the organization that owns the application, so the ordinary per-row tenant check decides who sees them and nothing about your tenants is sent to Odoo. The integer remote key identifies the source record; odooPartnerRef retains it on the local row.

The subject-rights posture follows from what the copied fields can hold. A client’s name and email can be a person’s: a sole trader is recorded as a company in Odoo, and a named contact’s address on a company record is theirs. The is_company filter leaves out the individual contacts filed under each account; it does not make the remaining records a legal person’s. So the destination declares name and email as personal data. The copy also keeps internalNote, which a team member writes and Odoo does not hold, so the binding declares OWNED_COPY: the application keeps this copy beyond its source and erases it itself, while Odoo, the company’s own system, answers requests about the records it came from. Without the note the copy would follow Odoo (HANDLED_AT_SOURCE, the default), leave an erasure to it, and remove what Odoo removes. NO_SUBJECT_DATA would be refused beside fields declared as personal data, and FORWARDED_TO_SOURCE is refused on a pipeline that only reads, since nothing sends a delete through it. purposeRefs names why the copy exists, so the privacy notice can list Odoo as a source of these fields for that purpose.

An erasure through the application does reach the client list. Each client row is its own data subject: it is no one’s login, so erasing it ends no session, and the person is found by what identifies them, usually the email. The todos done for a client point at its row, so an erasure keeps the row and scrubs name, email and internalNote in place and records an erasure suppression so the next pass does not copy that Odoo record back. A client that no longer appears in a complete pass, because it was deleted in Odoo, stopped matching the filter or can no longer be read, has the same three fields scrubbed in place by that pass (IMPERSONALIZE). That scrub writes no suppression, because nobody asked for the record to be forgotten: if Odoo lists it again, it loads as a new row, and the todos keep pointing at the scrubbed one. A row under legal hold is left unchanged. Reassess every one of these choices for a different population.

The two partition fields are written by the engine, never by the mapping. organizationUnitId receives the unit the client’s Odoo company is assigned to, which is what confines a subsidiary’s members to their own clients. odooCompanyRef keeps Odoo’s company_id itself, so a client still says which company it belongs to after that company is reassigned or unassigned. A company whose assignment is withdrawn is not mistaken for clients that disappeared, because each pass still reads its clients and counts them as present without loading them; until the company is assigned again, those clients are read by nobody here, organization-wide members included. Both stay empty on a client that belongs to no company, which every caller may see. The name shown is a declared expression: the client’s name followed by Odoo’s reference (Acme Industries · C-0042), with the separator dropped when there is no reference.

This pipeline runs a full pass every hour. An Odoo pipeline can instead read only the rows changed since its last pass, by write_date (ExternalDataIncrementalExtractionMode.ODOO_WRITE_DATE), with a periodic full pass to notice deletions, and an inbound notification can ask it to re-read particular records by id.

Write through to Odoo

Wonder Todos’ billable-service catalogue has no local table: it is read from Odoo’s product.template on every request, and its writes go straight to Odoo. The binding, comments omitted:

persistenceAdapter: PersistenceAdapter.HTTP_API,

httpApiBinding: {
  dialect: HttpApiTransportDialect.ODOO_JSONRPC,
  system: 'odoo',
  entityRef: 'product.template',
  keyFields: [{ localField: 'odooProductId', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
  fieldMappings: [
    { localField: 'listPrice', remoteField: 'list_price' },
    { localField: 'unitOfMeasure', remoteField: 'uom_name' },
    { localField: 'lastUpdatedAt', remoteField: 'write_date' },
  ],
  subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
  writableVerbs: [HttpApiWriteVerb.CREATE, HttpApiWriteVerb.UPDATE, HttpApiWriteVerb.DELETE],
  writeConcurrency: { mode: HttpApiWriteConcurrencyMode.VERSION_CHECKED, versionField: 'lastUpdatedAt' },
},

Each opened verb is one execute_kw call (create, write or unlink) under the instance’s own identity, and each has its operation, gated on ORG_MANAGER. Every write requires an Idempotency-Key: Odoo offers no idempotency of its own, so a write is sent once, an interrupted one answers that its outcome is unknown, and a retry under the same key gives that answer again instead of creating a second product. Under VERSION_CHECKED an update carries back the write_date the caller read, and is refused when Odoo’s is newer. A product is not a person, which is why this binding may declare NO_SUBJECT_DATA; pointing it at people would change that.

Run an Odoo method as a declared action

Posting an invoice is not a row update: Odoo validates it, numbers it and writes its journal entries. Wonder Todos’ customerInvoices binding on account.move declares it as an action, comments omitted:

actions: {
  [CUSTOMER_INVOICES_POST_ACTION]: {
    target: SystemOfRecordActionTarget.RECORD,
    call: {
      shape: SystemOfRecordActionCallShape.ODOO_EXECUTE_KW,
      method: 'action_post',
      inputPlacement: SystemOfRecordOdooActionInputPlacement.NONE,
    },
    input: z.object({}),
    writes: true,
    retrySafe: false,
    roles: [CORE_ORG_ROLES.ORG_MANAGER],
    operation: CustomerInvoices_Operations.POST_INVOICE,
  },
},

Its only HTTP door is the resource’s post_invoice operation, PUT …/customer-invoices/{id}/post_invoice, which requires an Idempotency-Key, is offered only on a draft, and hands the request to the system-of-record action executor. The executor checks the role in the owning organization, reads the invoice first so another company’s invoice answers as absent, sends one execute_kw('account.move', 'action_post', [[id]]), and records the outcome. Odoo offers no idempotency for a method call, so retrySafe is false and a lost answer is never re-sent.

Send local changes, or keep both sides in step

Wonder Todos’ Odoo pipelines only read. The same declaration can run the other way: an outbound pipeline creates in Odoo a record first made in the application, keeps the id Odoo answers, and sends later edits as updates. A two-way pipeline loads the fields Odoo owns, sends the fields the application owns, and for a field both sides edit applies its conflict policy or reports the conflict. Because Odoo serves write_date only to the second, two-way agreement also compares a fingerprint of the shared values, so a second edit within the same second is not missed.

Keep compatibility tied to the configured server

This dialect targets the classic JSON-RPC contract implemented by its compiler and client. Protocol-simulator tests exercise authentication, extraction, writes, actions, changing source populations and both sending directions; they do not certify every hosted Odoo version or deployment. Verify the instance’s exposed API and the integration user’s permissions before depending on it for a business workflow.

Read SAP S/4HANA data in your application Mechanism

A company running SAP S/4HANA can build internal tools on the records SAP already holds. An application declares the SAP system once, then each resource that needs SAP data either reads it live or keeps a copy.

Read in place: a list or record screen asks SAP at the moment it opens, so what people see is what SAP holds now. Copy kept current: a scheduled pipeline copies selected fields into the application’s own store, where they can sit beside fields only the application owns, such as an internal note or a status.

SAP publishes many of its most used services in the older OData version 2 and newer ones in version 4. Both are read. Version 2 values arrive as text, so Wildo reads each property’s type from the service’s own description and turns dates and amounts back into dates and numbers. A value that cannot be represented exactly is refused by name rather than silently rounded.

The connection is read-only. An application cannot create, change or delete records in SAP through this vendor, and a declaration that tries is refused when the application starts.

Example: Show business partners beside project work

A project-tracking tool copies business partners from SAP every hour so a project can be attached to a customer and annotated by the team. The partner’s name and identifiers stay SAP’s: they are shown read-only and refreshed on the next pass. The team’s notes belong to the application and are never sent back.

SAP business partners are copied hourly into a project as locked records, with separate team notes kept in the application.
For engineers

An instance names the vendor and the OData version of one service root. SAP’s classic Gateway services (/sap/opu/odata/sap/<SERVICE>) are version 2; its newer roots (/sap/opu/odata4/…) are version 4. A design example, in wildo.saas.config.ts:

systemsOfRecord: {
  'sap-bp': {
    vendor: 'sap-s4hana',
    dialect: HttpApiTransportDialect.ODATA_V2,
    credential: { kind: SystemOfRecordCredentialKind.BASIC },
  },
},

The facet offers two credential kinds for a communication user, HTTP Basic and a client certificate, so the instance names one; an instance that names neither is refused at startup. With Basic, each environment supplies the service root and the user name in wildo.infra.<env>.config.ts and the password in secrets.json; the variable names are derived from the instance name (WILDO_SOR_SAP_BP__BASE_URL, WILDO_SOR_SAP_BP__USERNAME, WILDO_SOR_SAP_BP__PASSWORD). A user name containing : is refused at startup, because Basic authentication uses the colon as its separator.

With MTLS_CLIENT_CERTIFICATE, the certificate goes in wildo.infra.<env>.config.ts and its private key in secrets.json (WILDO_SOR_SAP_BP__CERTIFICATE_PEM, WILDO_SOR_SAP_BP__PRIVATE_KEY_PEM), plus a CA bundle only when SAP’s server certificate is not one the platform already trusts. The certificate is presented in the TLS handshake, so the service root must be https:. An expired certificate, an encrypted key or a key that is not the certificate’s is refused at startup, and wildo config validate warns thirty days before the certificate expires.

A system reached through both versions is two instances, one per root, each with its own maxRequestsPerMinute. The two allowances are paced separately, so their sum is what must stay under what SAP grants the integration.

A binding’s dialect must match its instance’s. A version 2 root declared as ODATA is refused at the first read with a message naming the version, rather than failing on an unfamiliar response.

Copy a version 2 entity set

The following pipeline declaration is taken from the engine’s own version 2 integration test, which runs it against the SAP double on MongoDB and PostgreSQL. It reads A_BusinessPartner and maps three properties; the destination schema and registration are omitted.

const sapDeclaration = (): ExternalDataPipelineDeclaration => ExternalDataPipelineDeclarationSchema.parse({
  binding: {
    dialect: HttpApiTransportDialect.ODATA_V2, system: 'sap', entityRef: 'A_BusinessPartner',
    keyFields: [{ localField: 'id', remoteField: 'BusinessPartner', codec: HttpApiKeyComponentCodec.STRING }],
    subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
  },
  remoteKeyLocalField: 'remoteKey', population: ExternalDataDestinationPopulation.CLOSED, orphanPolicy: ExternalDataOrphanPolicy.DELETE,
  maxRowsPerRun: 3,
  mapping: [
    { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'BusinessPartnerFullName', localField: 'name', required: true },
    { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'CreationDate', localField: 'createdOn', required: true },
    { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'CreditLimitAmount', localField: 'creditLimit', required: true },
  ],
});

The pass walks the entity’s key (BusinessPartner gt '<last key>', ordered by key) instead of following the service’s __next link, because version 2 promises no stability across a partial listing. A row inserted or deleted while a pass is under way is therefore neither read twice nor skipped. CreationDate lands as a date and CreditLimitAmount as a number. A value no number can hold exactly rejects that one row, and the rest of the pass continues; the same value in a live read refuses the read, naming the property.

The test’s subject-rights posture describes its fixture. A business partner can be a person, so a real binding declares its fields’ personal-data categories and keeps the default posture, under which SAP answers for its own records; the framework refuses NO_SUBJECT_DATA beside a field declared as personal data.

Read only what changed

On a version 4 root, a pipeline can read only the rows changed since its last pass, through an OData $filter … ge on a timestamp property the pipeline declaration names. Whether an entity carries such a property is for the declaration to know. A version 4 pipeline can also re-read records by key when an inbound notification names them. Version 2 roots run full passes, each walked by key.

Work with Salesforce records in your application Mechanism

A company that keeps its customers and opportunities in Salesforce can build internal tools on those records without moving them. The application declares the Salesforce org once and signs in to it as an integration, never as one of its own users.

Read in place: a screen asks Salesforce when it opens, through a SOQL query the engine builds. Copy kept current: a scheduled pipeline copies chosen fields into the application, walking the object in Id order so a long copy neither skips nor repeats a record when Salesforce changes underneath it.

Write back: a resource can create, change and delete records in Salesforce. Each write is sent exactly once and recorded; if the answer is lost, the application reports that the outcome is unknown instead of trying again and creating a duplicate. A pipeline can also send records created or changed in the application to Salesforce.

The same records cannot be edited on both sides with Wildo deciding who wins. That needs a version each record carries, and the Salesforce facet declares none, so a two-way pipeline is refused when the application starts.

Example: Open accounts from a delivery tool

A delivery team’s tool reads Salesforce accounts live, so the list always matches the CRM. When a project starts for a new client, the tool creates the account in Salesforce from its own form; the new account’s Id comes back in Salesforce’s answer and the record is read back from Salesforce by it.

Salesforce accounts are read live; a new-account form sends a create request to Salesforce and receives its Id.
For engineers

The facet offers two credential kinds, so the instance names one. A design example, in wildo.saas.config.ts:

systemsOfRecord: {
  crm: {
    vendor: 'salesforce',
    dialect: HttpApiTransportDialect.REST_JSON,
    credential: { kind: SystemOfRecordCredentialKind.OAUTH2_JWT_BEARER },
  },
},

OAUTH2_CLIENT_CREDENTIALS_SECRET sends a client id and secret; OAUTH2_JWT_BEARER signs an assertion with a private key for a named integration user. Omitting credential is refused at startup, listing both. The org’s instance URL (https://<myDomain>.my.salesforce.com) is the baseUrl address slot. The engine deliberately does not take it from the token response, so sandbox and production differ by address alone. Under the JWT bearer grant the environment also supplies clientId, tokenUrl and subject (the integration user) as addresses and privateKeyPem as the secret. A token is obtained once per lifetime and shared by concurrent reads; when Salesforce’s answer omits a lifetime, the facet’s two-hour fallback applies.

Copy an object through a keyset

The binding below is taken from the engine’s keyset integration test, which runs it against the SOQL double on MongoDB and PostgreSQL. Its destination schema and registration are omitted.

binding: {
  dialect: HttpApiTransportDialect.REST_JSON, system: 'salesforce', entityRef: 'Account',
  keyFields: [{ localField: 'id', remoteField: 'Id', codec: HttpApiKeyComponentCodec.STRING }],
  subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
  operations: {
    read: { method: 'GET', pathTemplate: '/services/data/v59.0/sobjects/Account/{key}' },
    list: {
      method: 'GET', pathTemplate: '/services/data/v59.0/query', resultPath: 'records', cursorPath: 'nextRecordsUrl',
      statement: { language: HttpApiQueryStatementLanguage.SOQL, parameterName: 'q', keyset: { keyRemoteField: 'Id' } },
    },
  },
},

You write no SOQL. Each page is built as SELECT Id, <mapped fields> FROM Account WHERE … AND Id > '<last Id>' ORDER BY Id ASC LIMIT <n>. Salesforce may answer one page in several batches; the engine follows nextRecordsUrl to the end of the page, then stores the last Id, never the query locator, whose lifetime is not documented. Without a keyset, the object can be read live but cannot feed a pipeline: paging by offset is refused because a moving result skips rows. Ids compare in code-unit order, Salesforce’s own Id order, and a page served out of that order is refused rather than trusted.

Open writes on a read-through resource

A REST binding declares the request each opened verb sends. The engine’s write test declares Salesforce’s sObject shape like this (its remoteCallSemantics constant is omitted):

operations: {
  read: { method: 'GET', pathTemplate: `${SOBJECT}/{key}` },
  create: { method: HttpApiRestWriteMethod.POST, pathTemplate: SOBJECT, createdKey: { source: HttpApiRestCreatedKeySource.RESPONSE_BODY, path: 'id' } },
  update: { method: HttpApiRestWriteMethod.PATCH, pathTemplate: `${SOBJECT}/{key}` },
  delete: { method: HttpApiRestWriteMethod.DELETE, pathTemplate: `${SOBJECT}/{key}` },
},
writableVerbs: WRITE_VERBS,
writeConcurrency: { mode: HttpApiWriteConcurrencyMode.LAST_WRITER_WINS },

createdKey says where Salesforce answers the new record’s Id: in the body’s id. An update sends only the changed fields and is followed by a read-back. PUT cannot be declared, because it replaces a record and would erase the fields a partial update leaves out. Every write requires an Idempotency-Key from the caller; a retry under the same key replays the first outcome instead of sending again. LAST_WRITER_WINS is the only stance available: VERSION_CHECKED is refused at startup because the facet declares no row version.

Work with Dynamics 365 records in your application Mechanism

Dynamics 365 Customer Engagement keeps its data in Microsoft Dataverse, and that is what an application connects to. The application declares the environment once and reaches it as a registered Microsoft Entra application, never as one of its own users.

Read in place: a screen asks Dataverse when it opens. Copy kept current: a pipeline copies chosen columns and, on tables with change tracking, reads only the rows that changed or were deleted since its last pass. On other tables it can read the rows modified since the last pass.

Write back: a resource can create, change and delete Dataverse rows. Every row carries a version tag, and an update is sent with the tag the person was looking at, so Dataverse itself refuses a change made over someone else’s newer edit instead of silently overwriting it.

Two-way: because rows carry that tag, a single pipeline can keep a table edited both in Dataverse and in the application in step. Each field is declared as owned by one side or shared, and a field changed on both sides is resolved by the declared policy or reported for a person to decide.

Example: Account plans next to the CRM

An account-planning tool copies Dynamics 365 accounts every few minutes through change tracking. Planners edit the account’s next review date in the tool, and that field is sent to Dataverse; the account’s name stays Dataverse’s. If a salesperson changes the review date in Dynamics at the same time, the conflict is reported rather than settled by whichever write landed last.

Dynamics 365 change tracking feeds accounts whose names remain CRM-owned; next-review edits return to Dynamics and simultaneous edits are reported as conflicts.
For engineers

The vendor id is microsoft-dataverse, the name the Web API itself uses. An instance naming microsoft-dynamics-365 or microsoft-dynamics-crm is refused at startup with a message pointing at microsoft-dataverse, so one product never ends up with two descriptions. A design example:

systemsOfRecord: {
  crm: {
    vendor: 'microsoft-dataverse',
    dialect: HttpApiTransportDialect.ODATA,
    credential: { kind: SystemOfRecordCredentialKind.OAUTH2_CLIENT_CREDENTIALS_CERTIFICATE },
  },
},

The facet offers a client secret (OAUTH2_CLIENT_CREDENTIALS_SECRET) or a certificate (OAUTH2_CLIENT_CREDENTIALS_CERTIFICATE), so the instance names one. baseUrl is the environment’s versioned Web API root (https://<org>.crm<region>.dynamics.com/api/data/v9.2), not the organization URL. Under the certificate kind the environment supplies clientId, tokenUrl and the public certificatePem, from which the engine derives the thumbprint it puts in the assertion header, and the secret privateKeyPem.

Paging by continuation

The Dataverse Web API does not accept $skip, which is why it has a facet of its own rather than the generic OData one: an offset-paged list would serve its first page and be refused on the second. The engine follows @odata.nextLink, asks for a page size with Prefer: odata.maxpagesize, and appends the key to $orderby so that no row falls between pages. Reaching page N of a live list costs N requests and stops at a bound; a deep collection wants a filter.

Copy only what changed

The declaration below is taken from the engine’s incremental integration test, which runs it against the OData double on MongoDB and PostgreSQL. The destination schema and registration are omitted.

const accountsDeclaration = (incremental?: Record<string, unknown>): ExternalDataPipelineDeclaration => ExternalDataPipelineDeclarationSchema.parse({
  binding: {
    dialect: HttpApiTransportDialect.ODATA, system: 'dataverse', entityRef: 'accounts',
    keyFields: [{ localField: 'id', remoteField: 'accountid', codec: HttpApiKeyComponentCodec.STRING }],
    subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
  },
  remoteKeyLocalField: 'remoteKey', population: ExternalDataDestinationPopulation.CLOSED, orphanPolicy: ExternalDataOrphanPolicy.DELETE,
  maxRowsPerRun: 100,
  ...(incremental === undefined ? {} : { scheduleCron: '*/5 * * * *', incremental }),
  mapping: [{ kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'name', localField: 'name', required: true }],
});
const deltaDeclaration = () => accountsDeclaration({ mode: ExternalDataIncrementalExtractionMode.ODATA_DELTA_LINK, fullPassCron: '0 3 * * *' });

With ODATA_DELTA_LINK, each pass reads exactly the rows changed and removed since the last, applies the orphan policy to the removed keys only, and keeps the new delta link. Change tracking is enabled per table in Dataverse, and a pipeline on a table without it is refused at its first delta read, naming the cause. When Dataverse has expired the tracking position, the pass falls back to a full read and its run history says so. ODATA_MODIFIED_AT reads from the previous pass’s largest modifiedon with ge, so rows sharing an instant are re-read rather than skipped. A full pass still runs on fullPassCron. The test’s subject-rights posture describes its fixture; Dataverse accounts and contacts can hold personal data, so a real binding keeps the default posture, under which Dataverse answers for its own records.

Write with the row’s tag

A binding maps the row’s tag onto a local field and checks it on update. These two lines are condensed from the engine’s OData write test, whose binding maps the tag beside two business fields:

fieldMappings: [{ localField: 'rowVersion', remoteField: '@odata.etag' }],
writeConcurrency: { mode: HttpApiWriteConcurrencyMode.VERSION_CHECKED, versionField: 'rowVersion' },

The engine compares the caller’s version with the row it reads first, then sends PATCH with If-Match set to that same tag, so the check is atomic: a change landing in between is refused by Dataverse with 412, sent once and not retried. A row served without a tag is refused by name under VERSION_CHECKED. Under LAST_WRITER_WINS an update still carries If-Match: *, because Dataverse would otherwise re-create a row deleted in the meantime.

Work with Business Central records in your application Mechanism

A company running Microsoft Dynamics 365 Business Central can build internal tools on its customers, items and documents. The application declares the Business Central environment once and reaches it as a registered Microsoft Entra application, never as one of its own users.

Read in place: a screen asks Business Central when it opens. Copy kept current: a pipeline copies chosen fields and can read only the records modified since its last pass, with a periodic full pass to notice deletions.

Write back: a resource can create, change and delete records. Business Central requires every update to carry the version the writer last read, and the engine always sends it, so a change made over someone else’s newer edit is refused by Business Central instead of overwriting it.

Two-way: because records carry that version, one pipeline can keep records edited in both places in step, with each field owned by one side or shared, and simultaneous changes to a shared field resolved by the declared policy or reported for a person to decide.

Example: A field-service tool on top of the ERP

A field-service tool reads Business Central items live for its parts picker and copies customers every hour so visits can be planned against them. When a technician corrects a customer’s site contact, that field is written back to Business Central, conditioned on the version the technician saw.

Business Central supplies live items for a parts picker and an hourly customer copy, while corrected site contacts return through a version check.
For engineers

A design example:

systemsOfRecord: {
  erp: {
    vendor: 'microsoft-dynamics-365-business-central',
    dialect: HttpApiTransportDialect.ODATA,
    credential: { kind: SystemOfRecordCredentialKind.OAUTH2_CLIENT_CREDENTIALS_SECRET },
  },
},

The facet offers a client secret or a certificate, so the instance names one. baseUrl is the API service root for one tenant and one environment (https://api.businesscentral.dynamics.com/v2.0/<tenant>/<environment>/api/v2.0), so production and sandbox are the same declaration pointed at different environments. Business Central addresses a company as a path segment (…/companies(<id>)/customers). The address may therefore end at one company, which is how a deployment is limited to that company; reads and writes then reach that company and no other.

Write with the version Business Central requires

A binding maps the row’s entity tag and states how updates are checked, exactly as for any OData v4 vendor. A design example, following the binding of the engine’s OData write test:

fieldMappings: [{ localField: 'rowVersion', remoteField: '@odata.etag' }],
writableVerbs: [HttpApiWriteVerb.CREATE, HttpApiWriteVerb.UPDATE, HttpApiWriteVerb.DELETE],
writeConcurrency: { mode: HttpApiWriteConcurrencyMode.VERSION_CHECKED, versionField: 'rowVersion' },

Under VERSION_CHECKED the engine compares the caller’s version with the record it reads first and sends that same tag as If-Match; a record changed in between is refused by Business Central and the write is not retried. Under LAST_WRITER_WINS it still sends the tag it read, or *, because Business Central refuses an update without one. Every write is sent once, requires an Idempotency-Key from the caller, and is recorded with its outcome.

Read only what changed

An incremental pipeline declares ExternalDataIncrementalExtractionMode.ODATA_MODIFIED_AT with the timestamp property its entity carries, and a fullPassCron for the full passes that alone notice deletions. Each incremental pass reads from the previous pass’s largest timestamp with ge, so records sharing one instant are re-read, never skipped. Inbound notifications can also ask a pipeline to re-read particular records by key.

Work with NetSuite records in your application Mechanism

A company that runs its finance and operations on Oracle NetSuite can build internal tools on those records. The application declares the NetSuite account once and reaches it as a registered integration, authenticated by a certificate, never as one of its own users.

Read in place: a screen queries NetSuite through SuiteQL when it opens. Copy kept current: a scheduled pipeline copies chosen fields, walking the table by record id. NetSuite will not page past 100,000 results by offset; walking by id never reaches that ceiling, so a large table can be copied whole, and a record inserted or deleted during a long copy is neither read twice nor skipped.

Write back: a resource can create, change and delete NetSuite records through its record service. Each write is sent exactly once and recorded; a lost answer is reported as an unknown outcome rather than retried into a duplicate. A pipeline can also send records created or changed in the application to NetSuite.

The same records cannot be edited on both sides with Wildo deciding who wins: the facet declares no record version, so a two-way pipeline is refused when the application starts.

Example: Keep a customer list beside service tickets

A support tool copies NetSuite customers every night so tickets can link to them and carry the team’s own triage fields. When the team corrects a billing contact, the tool updates that field in NetSuite through the record service; the customer’s other fields stay as NetSuite holds them.

NetSuite customers are copied nightly and linked to local tickets, while only a corrected billing contact is sent back to NetSuite.
For engineers

The facet offers one credential kind, OAuth client credentials with a client assertion signed by a private key, so the instance does not name one. A design example:

systemsOfRecord: {
  erp: { vendor: 'netsuite', dialect: HttpApiTransportDialect.REST_JSON },
},

Both NetSuite hosts are account-specific, so neither is a constant: baseUrl is the REST services host (https://<accountId>.suitetalk.api.netsuite.com) and tokenUrl the token endpoint. A sandbox changes both. The environment also supplies clientId and certificateKeyId, the key id NetSuite assigns when the certificate is uploaded and expects in the assertion’s header, and the secret privateKeyPem. An encrypted private key is refused by name.

Copy a table through a keyset

The binding below is taken from the engine’s keyset integration test, which runs it against the SuiteQL double on MongoDB and PostgreSQL. The destination schema, mapping and registration are omitted.

binding: {
  dialect: HttpApiTransportDialect.REST_JSON, system: 'netsuite', entityRef: 'customer',
  keyFields: [{ localField: 'id', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
  subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
  operations: {
    read: { method: 'GET', pathTemplate: '/services/rest/record/v1/customer/{key}' },
    list: {
      method: 'POST', pathTemplate: '/services/rest/query/v1/suiteql', resultPath: 'items',
      pagingParams: { limitParam: 'limit', offsetParam: 'offset', placement: HttpApiRestParameterPlacement.QUERY_STRING },
      staticHeaders: { Prefer: 'transient' },
      statement: { language: HttpApiQueryStatementLanguage.SUITEQL, parameterName: 'q', keyset: { keyRemoteField: 'id' } },
    },
  },
},

The statement travels in the body of a POST and the paging in the URL, which is why placement is declared: placed by method, the limit would land in the body and bound nothing, so such a member is refused where it is declared. Each page is built as … WHERE id > <last id> ORDER BY id ASC with limit=<n>&offset=0. NetSuite serves ids as text; the integer codec decodes them so they compare as numbers, and a page served out of order is refused rather than trusted. A short page that NetSuite flags with hasMore does not end the pass.

The test’s subject-rights posture describes its fixture. A customer record can describe a person, so a real binding declares its fields’ personal-data categories and keeps the default posture, under which NetSuite answers for its own records; the framework refuses NO_SUBJECT_DATA beside a field declared as personal data.

Write through the record service

Each opened verb declares one request: a create as POST on the record type, with createdKey: { source: HttpApiRestCreatedKeySource.LOCATION_HEADER }; an update as PATCH and a delete as DELETE on …/{key}. NetSuite answers a create with 204 and the new record’s URL in Location. The engine reads the key only when that URL names a record of the collection the create was sent to, then reads the row back by it; any other Location is an unknown outcome, never a guessed key. writeConcurrency must be LAST_WRITER_WINS, because the facet declares no record version.

Connect any OData v4 service Mechanism

Internal tools often sit on systems no catalogue lists: a bespoke middleware, an ERP’s own service layer, a vendor nobody has described yet. When such a system speaks OData v4, the open standard many ERP and CRM products publish, an application can declare it as a system of record without waiting for Wildo to support that vendor by name.

Everything the OData v4 protocol defines is available. Read in place: a screen asks the service when it opens. Copy kept current: a pipeline copies chosen fields, and can read only what changed when the service offers change tracking or a modification timestamp. Write back: records can be created, changed and deleted, each update conditioned on the version tag the service attaches to the record, so a stale change is refused by the service itself when it keeps such tags. Actions: an operation the service exposes, such as releasing an order, can be declared and run under the same controls as a write. Two-way: where records carry version tags, one pipeline can keep records edited on both sides in step.

The generic facet knows nothing vendor-specific, and that is its cost. When a vendor has its own facet, as Dataverse and Business Central do, that facet is the better choice: it carries the corrections that vendor needs.

Example: An in-house logistics service

A company exposes its warehouse system through an OData v4 service of its own. Its operations tool reads stock levels live, copies shipments every few minutes through the service’s change tracking, and runs the service’s own “release shipment” action from a button, with the action recorded like any other change.

An OData warehouse service supplies live stock and copied shipments, while the application sends a release-shipment action that leaves a record.
For engineers

The generic facet offers five credential kinds, so the instance names the one its service accepts. A design example:

systemsOfRecord: {
  logistics: {
    vendor: 'odata',
    dialect: HttpApiTransportDialect.ODATA,
    credential: { kind: SystemOfRecordCredentialKind.OAUTH2_CLIENT_CREDENTIALS_SECRET },
    maxRequestsPerMinute: 600,
  },
},
KindWhat the environment supplies
API_KEY_HEADERapiKey as a secret, sent as Authorization: Bearer <key>
BASICusername as an address, password as a secret
OAUTH2_CLIENT_CREDENTIALS_SECRETclientId and tokenUrl as addresses, clientSecret as a secret; the client authenticates in a Basic header, the form OAuth requires every server to accept
OAUTH2_CLIENT_CREDENTIALS_CERTIFICATEclientId, tokenUrl and the public certificatePem; the privateKeyPem that signs the assertion as a secret
MTLS_CLIENT_CERTIFICATEthe public certificatePem, the privateKeyPem as a secret, and a caBundlePem only when the service’s certificate is not one the platform already trusts; the certificate is presented in the TLS handshake, so baseUrl must be https:

baseUrl is the OData service root, the URL an entity-set name is appended to, not the host. An instance that names no credential kind is refused at startup with the five listed. maxRequestsPerMinute paces every resource bound to the instance together.

What the protocol gives you, and what the service must have

A live list pages with $top and $skip; a page the service cuts short is read to its end through @odata.nextLink. A pipeline asks for its page size with Prefer: odata.maxpagesize and follows the service’s links. Incremental passes use either ODATA_MODIFIED_AT (a ge filter on a timestamp property the pipeline names) or ODATA_DELTA_LINK (the service’s change tracking); a service that tracks no changes answers without a delta link, and that pass is refused by name.

Writes send POST, PATCH and DELETE, never PUT, with If-Match carrying the tag read first. Under VERSION_CHECKED the comparison and the service’s own check use the same tag, so a concurrent change is refused with 412 and not retried. A record the service serves without a tag is refused by name under VERSION_CHECKED, and is still sent If-Match: * under LAST_WRITER_WINS so that a deleted record is never re-created.

Actions are declared on the binding of the entity they act on, as SystemOfRecordActionCallShape.ODATA_BOUND_ACTION with the action’s qualified name, or ODATA_UNBOUND_ACTION with its action import. The engine calls them the way the protocol defines; whether the service exposes a given action is the declaration’s to know.

A vendor that deserves its own facet

Each vendor Wildo describes is one authored facet file in the @wildo-ai/systems-of-record library: the dialects it speaks, the address and credentials it needs, how it pages, whether it versions rows, how it partitions, which actions it accepts. Adding a vendor to the framework is adding such a file and its row in the library’s record. A package can also add a partition field or a further credential kind to a facet it does not own. That addition applies only to an instance that names the package in facetAugmentations, and two sources declaring the same partition or credential kind are refused at startup, naming both.

Declare it once, confine it by company

Declare the ERP or CRM your application builds on Mechanism

An internal tool is built on top of systems the company already runs. Wildo calls such a system a system of record: the place where some of the application’s own business records live. The application names each one once, as an instance: “our Odoo”, “the SAP finance system”. Every resource that reads from it, writes to it or copies from it refers to that name.

Where the system is and how to sign in to it belong to each environment, not to the code. Production points at the real ERP, a test environment at a sandbox, and a developer’s machine can state that it has no access at all. The application still starts there, and screens that need the ERP say it is unavailable instead of pretending it is empty.

When the application starts it checks that every declared system is described by a known vendor and fully addressed: a misspelled vendor or a missing password stops the start with a message naming it. It does not contact the ERP to do so, so an ERP outage never prevents the application from starting.

A system of record is not one of the application’s providers, such as its email or payment service. It is the company’s own deployment, reached with the application’s own service identity, and the records read from it belong to the organization that owns the application. Nobody’s personal account is used, and no person can connect their own account to it.

Example: Two Odoo databases

A group runs its French and German companies in two separate Odoo databases. The application declares two instances, odoo-fr and odoo-de, of the same vendor. They differ only in their addresses and keys, which each environment supplies under names derived from the two instance names.

One declaration names separate odoo-fr and odoo-de databases, with each environment supplying their addresses and keys.
For engineers

Wonder Todos declares its Odoo in wildo.saas.config.ts; the explanatory comments are omitted:

systemsOfRecord: {
  odoo: {
    vendor: 'odoo',
    dialect: HttpApiTransportDialect.ODOO_JSONRPC,
    partition: true,
    personalDataOrigin: SystemOfRecordPersonalDataOrigin.NO_PUBLICLY_ACCESSIBLE_SOURCES,
  },
},

The key, odoo, is the instance name: lower-kebab-case, at most 32 characters, because it becomes part of environment-variable names. A resource binds it with system: 'odoo', and startup refuses a binding naming an undeclared instance or a dialect that disagrees with the instance’s.

FieldDecides
vendorwhich vendor facet describes the system (odoo, sap-s4hana, salesforce, netsuite, microsoft-dataverse, microsoft-dynamics-365-business-central, or odata for any other OData v4 service)
dialecthow it is spoken to; one instance has one dialect and one base address
credential.kindhow the application proves its identity; omitted when the facet offers only one kind, required when it offers several
maxRequestsPerMinutethe pace shared by every resource bound to the instance, since the vendor counts them as one integration
partitionwhether this deployment’s records are confined per company; see confining a system of record by company
facetAugmentationspackages whose additions to the vendor facet apply to this instance
personalDataOriginwhether personal data in it may come from public sources, which the privacy notice states

Declaring the block requires an organization type that declares ownsApplication: rows read from a system of record are stamped with that organization’s id, so the ordinary per-row tenant check decides who sees them. An empty systemsOfRecord is refused; omit the block instead.

Supply it per environment

Each environment’s wildo.infra.<env>.config.ts either addresses the instance, as in the configuration schema’s own example, or declares it absent:

systemsOfRecord: {
  odoo: { address: { baseUrl: 'https://acme.odoo.com', database: 'acme', login: 'bot@acme.com' } },
}
systemsOfRecord: {
  odoo: { enabled: false },
},

The second form is Wonder Todos’ local configuration. Identity values (an address, a database, a login, a client id) live in this file, where they can be reviewed in a diff; the proof (an API key, a password, a private key) lives in the environment’s secrets.json. Which slots are needed is the vendor facet’s and credential kind’s to say. Nobody writes a variable name: each is derived as WILDO_SOR_<NAME>__<SLOT>, here WILDO_SOR_ODOO__BASE_URL, __DATABASE, __LOGIN and __API_KEY. The double underscore cannot occur in either half, so every name parses back to its instance and slot. Renaming an instance renames its variables, and wildo config sync reports the slots that are then missing.

An environment that omits a declared instance entirely is refused, both by wildo config sync and at startup: silence could equally mean “no access here” and “someone forgot the address”, and those need opposite behaviour. With enabled: false, the application starts, reads of bound resources answer 409 with SYSTEM_OF_RECORD_UNAVAILABLE_IN_ENVIRONMENT, and scheduled pipelines are not dispatched.

What startup checks, and what it does not

At startup the engine joins each declaration with its vendor facet and its environment, once, and every consumer reads the result. It refuses a vendor no facet describes (an alias such as microsoft-dynamics-365 is refused with the vendor id that carries the facet), a dialect the facet does not speak, a credential kind it does not offer, several offered kinds with none chosen, and a required slot the environment did not supply. It never dials the system: whether the remote answers is discovered by the first read, which reports a transport error.

The vendor facets are an opt-in library

The facets live in @wildo-ai/systems-of-record, not in the engine. An application that declares no system of record never receives them. Once systemsOfRecord is declared, wildo config sync (and every other command that regenerates provider artifacts) adds the library to each backend service and links it, then refuses to continue until it is installed, because a backend without it would refuse every instance.

Why a system of record is not a provider

The read lane and the connector lane share an HTTP transport and nothing else. pnpm check:systems-of-record-separation refuses any import from the system-of-record lane into provider or connected-account code, and the reverse. A system of record is never selected through the capability plane, never spends a person’s connected account, and its registry is not exposed to application code, which reaches an instance only through the resources bound to it.

Keep each subsidiary to its own company's records Mechanism

A group often runs several companies inside one ERP: one Odoo database holding the French, German and Spanish entities, for example. Without confinement, everybody in the application would see every company’s customers and invoices, because all of them belong to the same organization in the application.

An operator maps each company in the ERP to a unit of the organization. A person granted a role on a unit then works within that company: their lists show its records and the records that belong to no company, a record of another company answers as if it did not exist, a record they create lands in their company, and an action such as posting an invoice is refused on another company’s invoice. The restriction is sent to the ERP as part of every query, so totals and pages are correct and nothing is fetched only to be thrown away.

A company that nobody has mapped is read by nobody, not even by organization-wide administrators. Every mapping, move and removal is made by an application super-administrator, carries a written justification, and is recorded with the unit before and after.

Example: Finance managers per subsidiary

A group’s Odoo holds two companies. The operator maps company 1 to the unit “Acme SA” and company 2 to “Globex SARL”. A finance manager granted on the Acme unit lists invoices and sees Acme’s and the shared ones; opening a Globex invoice answers “not found”, and posting it is refused: the only call that reaches Odoo is the read that found the invoice belongs to Globex. A third company, created in Odoo later and not yet mapped, appears to nobody until the operator maps it.

Odoo companies map to Acme SA and Globex SARL separately; an Acme finance manager sees its invoices and receives Not found for a Globex invoice.
For engineers

Partitioning is a property of the deployment, declared on the instance; how the vendor partitions is the facet’s. Wonder Todos’ declaration, comments omitted:

systemsOfRecord: {
  odoo: {
    vendor: 'odoo',
    dialect: HttpApiTransportDialect.ODOO_JSONRPC,
    partition: true,
    personalDataOrigin: SystemOfRecordPersonalDataOrigin.NO_PUBLICLY_ACCESSIBLE_SOURCES,
  },
},

The Odoo facet declares the partition as the company_id field, read as Odoo’s id-name pair and compared as an integer. Both were measured against a live multi-company Odoo: a domain comparing the id as text matches nothing and reports no error, so a wrong codec would have shown members empty lists that looked correct. A record whose company_id is unset is shared and belongs to every caller. partition: true on a vendor whose facet declares no partition is refused at startup, naming the vendor.

Map companies to units

Assignments are an application-scoped engine resource, systemOfRecordPartitionAssignments, reached at /applications/{applicationId}/system-of-record-partition-assignments by an application super-administrator. Each row names the instance, the remote key in canonical text and the unit, with a justification. One remote partition maps to at most one unit. A move is its own operation, which states the unit it expects to replace and is refused if another operator moved it first. Every transition is written to the audit trail with the unit it left.

Gate reads so confinement actually applies

Unit narrowing is additive: a role a person holds across the whole organization wins over one held on a unit. A read-through resource bound to a partitioned instance must therefore gate its reads on a role above the default member role, or startup refuses it, because every member would otherwise reach every company. Wonder Todos’ billableServices and customerInvoices gate reads, writes and the invoice action on ORG_MANAGER; a person granted that role on one unit only is confined to that unit’s company.

What each operation does under confinement
OperationConfinement
List, count, searchthe predicate is pushed to the ERP: for Odoo a domain matching the assigned company ids or an unset company_id, for OData an eq or-chain, for SuiteQL or SOQL a statement clause
Read by keya record of another company answers as not found
Createlands in the partition of the caller’s unit; the partition field is never accepted from the caller
Update, delete, record actionthe record is read first; another company’s record is refused as absent, and only that read leaves the application
Change to a shared recordallowed only to an organization-wide caller, since it affects every company
Copy-in pipelineeach copied row is stamped with the unit its company is assigned to (partitionUnitLocalField) and keeps the remote company key (partitionKeyLocalField); both are required on a partitioned instance. A row whose company is unassigned after it was copied is read by nobody until the company is assigned again
Outbound pipelinea created row is placed by its unit; an update is sent only if the record’s current company is the row’s unit’s

Read in place and write back

Work with records in their original system Mechanism

Some information already has a home: product prices in an ERP, invoices in an accounting system or a catalogue maintained by another team. Your application may need to show those records, and sometimes let people correct them, without taking responsibility for a second copy.

A virtual resource reads from the external system when the application asks for it. Wildo maps the remote fields into the resource’s declared shape, so the information appears in the application’s screens, API and permissions like any other record. When you open creates, updates or deletes, each change is sent to that system once, under the application’s own service account, and recorded.

The external system keeps the only copy. This is a useful fit for information people consult or correct in passing, especially when keeping a local copy would create another synchronization job.

Example: Consult and correct the company’s service catalogue

Wonder Todos reads billable services from the company’s Odoo catalogue. A manager sees the service name, price and unit in the application and can correct a price there; the change is written to Odoo, which remains the catalogue. Before writing, the application checks that nobody changed the service in Odoo since it was read, so most conflicting edits are refused rather than silently overwritten.

A manager reads the Odoo service catalogue, corrects a price, and writes it to Odoo after a version check.
For engineers
Declare the source, its field mapping and its writes

The following excerpt from Wonder Todos’ billable-services.resources-config.ts selects the HTTP API adapter and binds it to the Odoo instance the application declares under systemsOfRecord. Source comments are omitted; the resource’s operation configurations follow this excerpt. HttpApiWriteVerb and HttpApiWriteConcurrencyMode come from @wildo-ai/saas-models/external-data.

persistenceAdapter: PersistenceAdapter.HTTP_API,

httpApiBinding: {
  dialect: HttpApiTransportDialect.ODOO_JSONRPC,
  system: 'odoo',
  entityRef: 'product.template',
  keyFields: [{ localField: 'odooProductId', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
  fieldMappings: [
    { localField: 'listPrice', remoteField: 'list_price' },
    { localField: 'unitOfMeasure', remoteField: 'uom_name' },
    { localField: 'lastUpdatedAt', remoteField: 'write_date' },
  ],
  subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
  writableVerbs: [HttpApiWriteVerb.CREATE, HttpApiWriteVerb.UPDATE, HttpApiWriteVerb.DELETE],
  writeConcurrency: { mode: HttpApiWriteConcurrencyMode.VERSION_CHECKED, versionField: 'lastUpdatedAt' },
},

coreOperations: [
  CoreResourceOperation.READ,
  CoreResourceOperation.LIST,
  CoreResourceOperation.CREATE,
  CoreResourceOperation.UPDATE,
  CoreResourceOperation.DELETE,
],

system names the declared instance, and entityRef the remote model. keyFields defines how a remote row is identified: one integer component, so the row’s id is Odoo’s own. fieldMappings translates names where the application and remote system differ: listPrice reads list_price, while fields with matching names keep them.

writableVerbs opens the writes, and each opened verb must have its operation: the configuration factory refuses either without the other, and it derives a required idempotency key on every write. writeConcurrency states how an update treats a record changed since it was read. VERSION_CHECKED compares the write_date the caller read, mapped to lastUpdatedAt, with Odoo’s own just before writing, and refuses a stale update.

Declare only the queries the remote dialect can execute

The same Odoo resource’s LIST variant declares:

sortFields: ['name', 'listPrice', 'odooProductId'],

listPrice maps to the remote list_price field. A display value without an orderable remote counterpart should not be advertised as sortable.

DialectLIST/SEARCH sort declaration
ODOO_JSONRPC, ODATA or ODATA_V2Declare a nonempty set of sortable fields; startup refuses an absent declaration.
REST_JSONDo not declare sort fields: this adapter path does not translate them, so order belongs in the declared statement.

The application also declares how callers address the resource. Wonder Todos routes its catalogue under the organization:

createResourcesRelationship(
  CoreResourceType.ORGANIZATIONS, TasksManager_ResourceType.BILLABLE_SERVICES,
  ResourceRelationshipCardinality.ONE, ResourceRelationshipCardinality.MANY,
  {
    nature: RelationshipNature.REFERENCE,
    isPrimaryScope: true,
    accessScopeStrategy: ResourceRelationshipAccessScopeStrategy.REQUIRES_CONTEXT,
    contextPolicy: { enabled: false },
  }
),

This relationship mounts the collection under /organizations/{org}/billable-services, which is what lets the access check name an organization; the operation roles grant organization managers access. It invents no organization column in Odoo. The requested organization is compared with the organization that owns the system of record, and any other organization receives an empty list.

Keep source access and application access connected

system resolves the instance’s configured address and service credential rather than embedding credentials in the resource; each environment supplies them. The repository uses the shared HttpApiReadClient for transport and dialect-specific request construction. An ordinary authorized read becomes the corresponding remote request, whose result is projected into the local resource shape; an authorized write becomes one remote create, write or delete.

Rows belong to the organization that owns the application: each is stamped with that organization’s id, and the ordinary per-row check decides who sees it. When the instance is declared partitioned — Odoo, by company — rows are further confined to the organization units an operator assigned each company to, and a member confined to one company can create and change only that company’s records. See confining a system of record by company. The subject-rights posture describes the bound data and is a separate responsibility from transport configuration.

Choose virtual data when the source should keep the only copy

A virtual resource has no local record to enrich with extra columns, attachments or a retrieval index. Requests depend on the external system’s response time and availability, and the source’s dialect determines which queries can be translated.

For this catalogue, those properties fit the product: prices are maintained in Odoo, and a correction made in the application lands there. When people need local annotations, attached files or ordinary local relationships around imported records, use a copy-in pipeline instead.

The read-through resource and the pipeline share remote-request machinery, while keeping their different storage and ownership choices explicit.

Change ERP records from your application Mechanism

A record type that the application reads live from the company’s ERP or CRM can also be written live. The application declares which of create, change and delete it opens, and each request then becomes one change in that system. Nothing is copied locally: the record still lives where it lived before, and the next read shows it as the ERP now holds it.

Every write addresses one record. A request carries an idempotency key, so a retried request replays the outcome of the first one instead of writing a second time. When the request has left and its answer is lost, nobody knows whether it landed. The application then says the outcome is unknown and refuses to send that request again, so a person can look in the ERP before anything else happens.

Writes stay inside the company that owns the data. Only the organization that owns the application writes. A member whose access is limited to one subsidiary can create records only in that subsidiary and change only that subsidiary’s records. A record outside their reach is answered as absent, exactly as a read answers it, and nothing is sent to the ERP.

An update states how it protects a change made since the person read the record. It is either checked against the version they read, or declared as last-writer-wins. Every attempt, whether applied, refused, replayed or of unknown outcome, is recorded in the audit trail with the fields it wrote.

Example: Correct a price in the service catalogue

A unit manager corrects the list price of a service held in the company’s Odoo catalogue. The update carries the version of the record they opened. If a colleague changed that service in Odoo in the meantime, the update is refused rather than silently overwriting the colleague’s change, and the manager reloads and decides again.

An application price correction reaches the ERP through a version check, while an attempt against a changed version is refused.
For engineers

Writes are opened on the binding of a read-through resource (persistenceAdapter: PersistenceAdapter.HTTP_API). The following is the binding of Wonder Todos’ billableServices catalogue, with its comments omitted. HttpApiWriteVerb, HttpApiWriteConcurrencyMode and HttpApiKeyComponentCodec come from @wildo-ai/saas-models/external-data; the dialect and the subject-rights posture from @wildo-ai/saas-models.

httpApiBinding: {
  dialect: HttpApiTransportDialect.ODOO_JSONRPC,
  system: 'odoo',
  entityRef: 'product.template',
  keyFields: [{ localField: 'odooProductId', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
  fieldMappings: [
    { localField: 'listPrice', remoteField: 'list_price' },
    { localField: 'unitOfMeasure', remoteField: 'uom_name' },
    { localField: 'lastUpdatedAt', remoteField: 'write_date' },
  ],
  subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
  writableVerbs: [HttpApiWriteVerb.CREATE, HttpApiWriteVerb.UPDATE, HttpApiWriteVerb.DELETE],
  writeConcurrency: { mode: HttpApiWriteConcurrencyMode.VERSION_CHECKED, versionField: 'lastUpdatedAt' },
},

writableVerbs opens the writes; leaving it out keeps the binding read-only. Each opened verb needs the matching core operation in coreOperations, and the factory refuses either without the other, so the write door and the route can never drift apart. The write operations in that file declare no idempotencyKey: the factory derives it as required on every write of a read-through resource, refuses a weaker setting, and requires the operation to run inline, because a queued execution could not carry the key. Bulk operations (createMany, updateMany, deleteMany) are refused on a read-through resource whatever the binding opens, since N remote calls with no transaction offer no atomicity.

writeConcurrency is required as soon as UPDATE is opened. VERSION_CHECKED names the local field that carries the version the caller read; it must map onto the vendor’s row version (write_date for Odoo, @odata.etag for an OData service), and the factory checks that an update can carry it and a create cannot send it. LAST_WRITER_WINS states the opposite choice, so it is a decision rather than an accident.

What the engine does with each write

The repository runs every write in one fixed order, and the order is why a refusal is always safe to retry:

  1. It refuses to run inside a database transaction, because a driver may retry a transaction callback and a remote write cannot roll back with it.
  2. It checks that the verb is opened and the dialect can send it (also checked at startup).
  3. It refuses a write scoped to any organization other than the owner of the application.
  4. It refuses, rather than strips, engine-owned fields in the payload — the owner stamp, a key component, the partition field — and renames the rest to the remote spelling.
  5. It spends the Idempotency-Key in the admission ledger before anything reads the remote.
  6. A create is placed in the caller’s unit’s partition; an update or delete reads the record first through every read gate and then asks whether this caller may write it. On update, the version check runs here.
  7. It sends one write through the dialect.
  8. It settles the ledger entry and records the attempt in the audit trail.

Nothing before step 7 has reached the ERP, so every refusal up to there settles the key as not applied and the request can be retried once its cause is fixed.

How retries and lost answers are answered

The ledger identifies a request by caller, resource, verb, target record and a fingerprint of the payload. A retry of an applied request replays it and reads the record back through the read gates, so a grant revoked in between still applies. A key reused with a different payload is answered 422 with the message reference idempotency_key_reused: never retry that request as it is. A request whose first attempt is still in flight, or whose outcome is unknown, is answered 409 with the code SYSTEM_OF_RECORD_WRITE_IN_PROGRESS (wait and retry) or SYSTEM_OF_RECORD_WRITE_OUTCOME_UNKNOWN (look in the ERP first). The audit trail records the attempt either way. A key is kept for one day.

An outcome is classified as unknown only once the write request has actually left: an Odoo write that failed during its authentication handshake wrote nothing, and is not reported as unknown.

What each vendor allows
Vendor and dialectWritesHow an update is protected
Odoo (JSON-RPC)create, write, unlink through execute_kwVERSION_CHECKED on write_date, best-effort, or LAST_WRITER_WINS
Microsoft Dataverse, Business Central, generic OData v4POST, PATCH, DELETE on the entity setVERSION_CHECKED, sent as If-Match and enforced by the service, or LAST_WRITER_WINS
Salesforce, NetSuite (REST JSON)the method and path the binding declares per verbLAST_WRITER_WINS only
SAP S/4HANArefused at startup—
Any OData v2 service rootrefused at startup—

On Odoo the comparison and the write are two calls, so the check narrows the lost-update window without closing it. On OData the service compares the tag itself and answers 412 without applying anything, which the engine reports as SYSTEM_OF_RECORD_WRITE_PRECONDITION_FAILED; even under LAST_WRITER_WINS the engine sends the tag of the record it read first, so a record changed or deleted between that read and the write is not overwritten or recreated.

Run ERP operations such as posting an invoice Mechanism

Some changes in an ERP are not edits of a record. Posting an invoice validates it, gives it a number and writes the journal entries; confirming an order starts delivery. Only the ERP’s own operation can do that. An application declares such an operation by name, beside the record type it acts on, and the engine runs it for the people allowed to.

The operation runs under the ERP connection’s own service account. Application code never holds that credential: it asks the engine to run the declared action, and the engine decides whether it may.

The person must hold one of the declared roles in the organization that owns the application. A role held only in one subsidiary allows the action only in that subsidiary’s company. An action on a record reads the record first through the same checks as an ordinary read, so a record outside the person’s reach is answered as absent and nothing is sent.

The input is checked against a declared shape, and anything the declaration does not name is refused rather than forwarded. An action that changes the ERP requires an idempotency key, so a retried request replays its first outcome instead of posting twice. If the answer is lost after sending, the outcome is reported as unknown, never as “not done”. Every attempt, refused or executed, is recorded in the audit trail.

Example: Post a draft invoice

A finance manager of one subsidiary posts a draft customer invoice from the application. The ERP validates and numbers it, and the application shows the invoice as posted, read back from the ERP rather than assumed. The operation is enabled only while the invoice is a draft, and an invoice belonging to another subsidiary cannot be posted from that manager’s account.

Posting a draft invoice asks the ERP to number it and reads the invoice back as posted.
For engineers

An action is declared on a read-through binding (httpApiBinding.actions). A record-targeted action belongs on the binding of the entity it acts on, because that binding supplies the key codec and the read gates the executor reads the record through. The following is the binding of Wonder Todos’ customerInvoices, with comments omitted. The action enums come from @wildo-ai/saas-models/external-data.

httpApiBinding: {
  dialect: HttpApiTransportDialect.ODOO_JSONRPC,
  system: 'odoo',
  entityRef: 'account.move',
  keyFields: [{ localField: 'odooInvoiceId', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
  fieldMappings: [{ localField: 'amountTotal', remoteField: 'amount_total' }],
  subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
  actions: {
    [CUSTOMER_INVOICES_POST_ACTION]: {
      target: SystemOfRecordActionTarget.RECORD,
      call: {
        shape: SystemOfRecordActionCallShape.ODOO_EXECUTE_KW,
        method: 'action_post',
        inputPlacement: SystemOfRecordOdooActionInputPlacement.NONE,
      },
      input: z.object({}),
      writes: true,
      retrySafe: false,
      roles: [CORE_ORG_ROLES.ORG_MANAGER],
      operation: CustomerInvoices_Operations.POST_INVOICE,
    },
  },
},

CUSTOMER_INVOICES_POST_ACTION is the application’s name for the action, 'postInvoice'; the call is what Odoo understands. writes: true makes the Idempotency-Key mandatory and turns an interrupted attempt into an unknown outcome. retrySafe: false stops the transport from re-sending after a timeout: Odoo offers no idempotency for a method call. operation names the action’s HTTP door.

Give the action an HTTP door

The door is a custom operation of the same resource. Because the action names it, the factory holds it to the action’s rules rather than the row-write rules: it must borrow a mutation verb, require the key and run inline. From the same file:

[CustomerInvoices_Operations.POST_INVOICE]: {
  variants: [{
    variantType: ResourceOperationVariantType.API_CALL,
    isDefault: true,
    roles: [CORE_ORG_ROLES.ORG_MANAGER],
    riskLevel: ResourceOperationRiskLevel.MEDIUM,
    resourceOperationLike: CoreResourceOperation.UPDATE,
    requestDto: z.object({}),
    idempotencyKey: ResourceOperationIdempotencyKeyRequirement.REQUIRED,
    enabledCondition: ({ currentObject }) => currentObject?.state === CustomerInvoices_State.DRAFT,
    customServiceImplementationModes: [ResourceOperation_CustomServiceImplementationMode.OVERRIDE_ALL],
  }],
},

The implementation (customer-invoices.post-invoice.operation.ts in the Wonder Todos backend) does nothing but hand the request to the executor and then read the invoice back through the resource’s own gates:

await actions.run({
  system: ODOO_SYSTEM_OF_RECORD,
  action: CUSTOMER_INVOICES_POST_ACTION,
  targetResourceId: id,
  input: {},
  idempotencyKey: utils.requestIdempotencyKey,
  serviceOptions: utils.serviceOptions,
}, executionContext);

utils.systemOfRecordActions is the only door to the executor. The systems-of-record registry that holds the credential is not reachable from application code, and the browser’s provider-operation route cannot reach an action.

What the executor checks, in order

The executor refuses to run inside a database transaction; checks that the action is declared on a binding of the named instance; refuses an anonymous, public or organization-less context; requires the owner organization and a declared role there; parses the input strictly; spends the key before anything reads the ERP; reads the record first (or, for a record-free action, places the call in the caller’s partition); sends one call; settles the key and writes one audit row. Every refusal before the call is recorded as system_of_record_action_refused, and every call as system_of_record_action_executed with its outcome.

A context with no user and no machine principal, such as a scheduled job, may run an action only when it declares admitsSystemInitiation: true, and it then acts organization-wide.

Call shapes and what each vendor offers
Call shapeWire formDeclared by
ODOO_EXECUTE_KWexecute_kw(db, uid, key, model, method, positional, keyword); input as keyword arguments, positional, or nonethe Odoo facet
ODATA_BOUND_ACTIONPOST <EntitySet>(<key>)/<Namespace.Action> with the input as JSON bodythe generic OData v4 facet
ODATA_UNBOUND_ACTIONPOST /<ActionImport> with the input as JSON bodythe generic OData v4 facet

A REST JSON binding cannot declare actions. On Odoo, the methods whose purpose is to return rows (search_read, read, read_group and similar) are refused, because an action’s answer is not confined the way the read lane confines rows; write and unlink on the bound model are refused as record actions, because they are the row writes writableVerbs already governs. An action that acts on no record, declared against a partitioned instance, must state its partition stance: PARTITION_NEUTRAL with a written reason, or MEMBER_UNIT_PARTITION naming the parameter the engine fills from the caller’s unit.

An optional output schema parses the vendor’s answer; an answer it rejects after the ERP applied the action is reported as an unknown outcome.

Keep a copy, or keep both sides in step

Use external records where your application needs them Mechanism

Data can remain in its original system, where your application reads it when needed and, where you allow it, changes it. Or it can become a synchronized part of your application, copied in, sent out or kept in step on both sides. Wildo supports each choice through resource declarations.

Read-through suits remote reference data and records people correct in passing. A local copy lets your application add its own fields, attachments and relationships, with freshness determined by the synchronization schedule.

Example: Keep customer accounts and local notes together

An application copies company accounts from Odoo, then lets its users add internal notes. A later synchronization updates the mapped account fields while the application retains the fields it owns.

External records can be read directly or synchronized into a local copy with application-owned notes.
For engineers

A virtual resource uses persistenceAdapter: HTTP_API with httpApiBinding. Its repository translates supported reads to the external system and sends single-record creates, updates and deletes for the verbs the binding opens; it has no local row for extra fields or files. A pipeline declaration belongs to an ordinary local resource and populates that resource through the data-seeding engines, or, declared outbound or two-way, sends its rows to the external system.

Both use HttpApiReadClient for access, dialect framing and remote calls. A binding names the system of record it reads, declared once under systemsOfRecord in wildo.saas.config.ts, and declares the remote entity and key mapping, which are required semantic decisions, not optional labels. Who answers for the records’ personal data defaults to the system of record and is declared only when it differs. A system of record belongs to one owner organization, and its rows are stamped with that organization’s id; on an instance declared partitioned, they are further confined to the organization units each remote company is assigned to.

This pipeline block is selected from Wonder Todos’ external-customers.resources-config.ts; its partition fields, source comments, and the surrounding resource factory and operation declarations are omitted:

  externalDataPipeline: {
    binding: {
      dialect: HttpApiTransportDialect.ODOO_JSONRPC,
      system: 'odoo',
      entityRef: 'res.partner',
      keyFields: [{ localField: 'id', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
      subjectRights: { posture: SystemOfRecordSubjectRightsPosture.HANDLED_AT_SOURCE },
      purposeRefs: ['task-planning-service'],
    },
    remoteKeyLocalField: 'odooPartnerRef',
    mapping: [
      {
        kind: ExternalDataMappingEntryKind.CONCATENATION,
        localField: 'name',
        parts: [
          { kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'name' },
          { kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'ref' },
        ],
        separator: ' · ',
      },
      { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'email', localField: 'email' },
    ],
    sourceFilter: { is_company: true },
    population: ExternalDataDestinationPopulation.CLOSED,
    orphanPolicy: ExternalDataOrphanPolicy.IMPERSONALIZE,
    scheduleCron: '0 * * * *',
  },

The closed population means the source supplies the records: this resource declares no caller create operation. Its local internalNote is editable, while imported fields exclude create/update input in the schema. The mapping owns only its declared fields, one of them built by a declared expression from two remote fields. The subject-rights posture says Odoo, which the application only reads, answers requests about its own client records, so an erasure that reaches these rows completes and its receipt names Odoo. A client that no longer appears in a complete pass has its copied name and email, and its note, scrubbed in place (IMPERSONALIZE), and the row is kept. Register the schema, resource factory and relationships through the shared module, and supply the system of record’s address and credential for each environment.

Treat synchronization as a population contract

remoteKeyLocalField holds a stable identity derived from the external binding. An extraction that reaches the source’s end selects sync reconciliation; a bounded pass selects upgrade reconciliation and persists its continuation. A persisted membership journal keeps the identities each run of a pass observed, so the final comparison covers the whole pass rather than only the last run’s rows.

RequirementSuitable lane
Read the external response on demandVirtual read-through
Change a record where it livesVirtual read-through with opened write verbs
Add local notes or filesLocal pipeline destination
Avoid remote latency on ordinary readsLocal pipeline destination
Send records the application createsOutbound pipeline
Let both sides edit one recordTwo-way pipeline, with each field’s owner declared
Keep no duplicated rowVirtual read-through

Start with REPORT until the population and its filter are understood: automatic orphan deletion acts on what a completed pass did not see, and a filter or permission change at the source can make records vanish from view without being deleted. A completed extraction is not a claim that every row passed transformation; inspect rejections and reconciliation results. Do not declare NO_SUBJECT_DATA for a source containing people’s information: the factory refuses it beside any mapped field whose declared category describes a person.

Bring external records into your application Mechanism

An application sometimes needs more than a view of another system’s data. People may want to attach files, add an internal note, connect records to their work or query them locally.

Wildo’s copy-in pipelines bring selected external values into an ordinary resource in the application’s database. The pipeline describes where the records come from, how fields map and how later runs reconcile changes.

This makes ownership clear. The remote system supplies its mapped fields; the application can keep its own fields beside them. A schedule controls how often the local copy is refreshed, and a pipeline can also read only what changed or re-read a record when its source announces a change.

Example: Keep customer accounts and local notes together

Wonder Todos synchronizes client accounts from the company’s Odoo into its external-customers resource. Odoo supplies the client’s name, shown with its finance reference, and its email; a team member can add an internal note locally. A later synchronization refreshes the mapped values without replacing the note.

An external catalogue is copied into application records alongside local notes.
For engineers
Map external values to local fields

This pipeline declaration is from Wonder Todos’ external-customers.resources-config.ts. The surrounding resource is locally stored and offers READ, LIST and UPDATE; source comments are omitted:

externalDataPipeline: {
  binding: {
    dialect: HttpApiTransportDialect.ODOO_JSONRPC,
    system: 'odoo',
    entityRef: 'res.partner',
    keyFields: [{ localField: 'id', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
    subjectRights: { posture: SystemOfRecordSubjectRightsPosture.OWNED_COPY },
    purposeRefs: ['task-planning-service'],
  },
  remoteKeyLocalField: 'odooPartnerRef',
  partitionUnitLocalField: 'organizationUnitId',
  partitionKeyLocalField: 'odooCompanyRef',
  mapping: [
    {
      kind: ExternalDataMappingEntryKind.CONCATENATION,
      localField: 'name',
      parts: [
        { kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'name' },
        { kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'ref' },
      ],
      separator: ' · ',
    },
    { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'email', localField: 'email' },
  ],
  sourceFilter: { is_company: true },
  population: ExternalDataDestinationPopulation.CLOSED,
  orphanPolicy: ExternalDataOrphanPolicy.IMPERSONALIZE,
  scheduleCron: '0 * * * *',
},

The binding names the declared Odoo instance and its res.partner entity. remoteKeyLocalField identifies the local business-key field used to reconcile a source row. The two partition fields are written by the engine, never mapped: one holds the organization unit the record’s Odoo company is assigned to, the other Odoo’s own company id, so a row still says which company it came from after that company’s assignment changes. Rows of a company left unassigned are read on every pass and counted as present without being loaded, so withdrawing an assignment never removes or scrubs a client; until the company is assigned again, nobody can read those rows.

The mapping names the values the pipeline owns. The CONCATENATION entry is a declared expression: the displayed name is the client’s name followed by its finance reference, and a missing reference is dropped with its separator. sourceFilter chooses client accounts rather than the contacts filed under them, and the hourly schedule expresses the application’s refresh cadence.

The subject-rights posture follows from the data. A client recorded as a company in Odoo can still be a sole trader, so the copied name and email describe people and NO_SUBJECT_DATA would be refused. The copy also keeps internalNote, the team’s own note about a client, which Odoo does not hold. A copy that keeps something its source does not is kept beyond the source, so it declares OWNED_COPY: the application erases the copy itself and keeps the next pass from loading the person back, while Odoo answers requests about the records it came from, which the erasure receipt states. A copy that only mirrored Odoo would follow it instead, and remove what Odoo removes.

Declare which local fields the source owns

The destination schema in external-customers.schemas.ts pairs that mapping with an explicit reconciliation key, engine-written company fields and read-only imported fields. Comments and unrelated fields are omitted:

odooPartnerRef: z.string().min(1).isBusinessKey().isUnique().isDBIndexed().isSummaryField()
  .excludeFromCreate().excludeFromUpdate(),

organizationUnitId: z.string().optional().isDBIndexed().isForeignKey().excludeFromCreate().excludeFromUpdate(),
odooCompanyRef:     z.string().optional().excludeFromCreate().excludeFromUpdate(),

name: z.string().optional().isSummaryField().excludeFromCreate().excludeFromUpdate()
  .dataCategories(PersonalDataCategory.IDENTITY),

email: z.string().optional().excludeFromCreate().excludeFromUpdate()
  .dataCategories(PersonalDataCategory.CONTACT_DETAILS),

internalNote: z.string().optional().dataCategories(PersonalDataCategory.USER_CONTENT),

The remote-key field must be a string marked both isBusinessKey() and isUnique(). Mapped fields must be excluded from caller CREATE and UPDATE. Optional source values need destination fields that admit absence: an Odoo client with no company is shared by every caller, so both company fields accept absence. The data categories on name and email are what a NO_SUBJECT_DATA posture would be checked against. internalNote is not mapped, so a refresh preserves the application’s own annotation.

Decide which records belong to the pipeline

population: CLOSED means every row comes from the source, so the resource does not offer caller creation. Local UPDATE remains useful for fields the pipeline does not own. Mapped destination fields are declared excluded from caller CREATE and UPDATE in the schema, keeping that ownership visible through the generated input and form.

orphanPolicy: REPORT records unmatched records without deleting them; a copy that follows its source and copies personal data may not declare it, because it would keep a record the source deleted. Disappearance reconciliation requires a completed pass with an accepted, complete source-membership history. An open population, where people also create rows, runs its declared policy too: only rows the pipeline loaded are ever compared with the source, so a row a person created is never removed.

This pipeline declares IMPERSONALIZE instead. A client absent from a complete pass has its name, email and note scrubbed in place, and the row stays because the todos done for the client point at it. The scrub writes no erasure suppression, so a record Odoo lists again loads as a new row. IMPERSONALIZE needs a destination that erases in place (RETAIN_AND_IMPERSONALIZE); the factory refuses it otherwise.

Extract, transform and reconcile through shared services

Extraction uses the same remote read client as virtual resources. A pure transformation maps each page, and the load step uses the existing data-seeding reconciler. Managed fields preserve local values outside the pipeline’s ownership; content comparison avoids rewriting rows whose mapped values have not changed.

Cursor and membership checkpoints let a bounded run continue without forgetting identities seen earlier. The final comparison uses that accepted history together with the current run’s observations, rather than treating earlier pages as vanished rows.

Pass outcomeReconciliation behavior
More pages remainLoad the current rows; defer orphan comparison.
Complete pass with identifiable source rowsCompare against the accumulated membership set.
A rejected row has no usable source identityLoad valid rows, but skip orphan inference, including REPORT.
Loading failsDo not advance the accepted cursor/checkpoint; replay from the accepted position.

The run summary separates extraction completion, load status and reconciliation status. A complete extraction is not by itself a successful import. A missing accepted membership chain is refused instead of silently replacing it with an empty set.

The resulting records support local resource behavior. Their source values reflect the last successful refresh; a virtual resource is the choice when each read should consult the remote system directly.

Refresh copied records by reading only what changed Mechanism

A pipeline that copies ERP records into the application can read the whole source on every pass, or read only what changed since the last one. Reading only the changes keeps a large copy current at a fraction of the calls, which matters when every call is a round trip to someone else’s production system.

The two kinds of pass answer different questions. A full pass reads every record and is the only pass that may conclude a record was deleted, since a record absent from a complete read is gone. An incremental pass reads what changed since a watermark taken from the ERP’s own change stamps, never from the application’s clock, and loads it. A full pass therefore stays scheduled beside it: the declaration requires one.

There are three ways to ask the ERP what changed: Odoo’s last-updated stamp, an OData property holding each record’s modification time, and OData change tracking, which also reports what the ERP deleted. The watermark moves only after the records that reached it are loaded, so a crash re-reads and never skips. Changing what the pipeline extracts or how it maps it resets the watermark, so the next incremental pass reads the whole source once and every record gets the new mapping.

Large populations are read page by page. Each page is loaded and checkpointed before the next is read, a pass may span several runs, and what a pass has already read is stored one row per record rather than kept in memory. A run therefore holds one page, whatever the size of the source.

Example: Keep a large account list current every ten minutes

An application keeps a copy of the accounts in the company’s Microsoft Dataverse. Every ten minutes it follows Dataverse’s change tracking and receives the accounts changed and those deleted since the last run. A nightly full pass reads every account and catches anything the tracking could not report.

Dataverse supplies changed and deleted accounts to a local copy every ten minutes, with a separate nightly full pass.
For engineers

incremental sits beside scheduleCron, which runs the incremental passes, and declares fullPassCron, which runs the full ones. The following are design examples, checked against the declaration schema; ExternalDataIncrementalExtractionMode comes from @wildo-ai/saas-models/external-data.

// Odoo: read what changed by `write_date`, re-reading the last five minutes at the start of each pass.
scheduleCron: '*/10 * * * *',
incremental: {
  mode: ExternalDataIncrementalExtractionMode.ODOO_WRITE_DATE,
  fullPassCron: '0 3 * * *',
  lookbackSeconds: 300,
},
// OData v4: a modification-time property this entity carries, named by its remote name.
incremental: {
  mode: ExternalDataIncrementalExtractionMode.ODATA_MODIFIED_AT,
  fullPassCron: '0 3 * * *',
  modifiedAtRemoteField: 'modifiedon',
},
// OData v4 change tracking: the service's delta link, which also reports deletions.
incremental: {
  mode: ExternalDataIncrementalExtractionMode.ODATA_DELTA_LINK,
  fullPassCron: '0 3 * * *',
},

scheduleCron is required with any of them, since nothing else would run an incremental pass. A full pass that needs several runs continues on scheduleCron ticks, interleaved with incremental passes; neither track waits for the other, and a record an incremental pass loads while a long full pass is in flight is never mistaken for a deletion when that full pass completes.

What each mode reads
ModeReadsWatermarkDeletions
ODOO_WRITE_DATErecords by (second, id) from the watermark, less lookbackSeconds (default 300)the last row loadedfull pass only
ODATA_MODIFIED_AT$filter=<field> ge <watermark − lookback>, in key orderthe largest timestamp read, moved only when the pass completesfull pass only
ODATA_DELTA_LINKthe whole set once with Prefer: odata.track-changes, then the service’s @odata.deltaLinkthe delta linkreported by the service, and handed to the orphan policy

Odoo stores write_date with microseconds and serves it to the second, so the Odoo mode finishes each second by id before moving on: every record is read once, however many share a second. The lookback re-reads recent changes because a long transaction can commit a record whose stamp is older than a watermark another pass already advanced past; a re-read costs nothing, since the load compares content and writes nothing for an unchanged record.

What startup refuses

The mode must be spoken over the binding’s dialect, and the vendor facet must declare it:

VendorModes
OdooODOO_WRITE_DATE
Microsoft Dataverse, generic OData v4ODATA_MODIFIED_AT, ODATA_DELTA_LINK
Business Central, SAP S/4HANA (OData v4 roots)ODATA_MODIFIED_AT
Salesforce, NetSuite, any OData v2 rootnone: full passes only

ODATA_DELTA_LINK is also refused with a sourceFilter, because change tracking refuses $filter, and with a composite key, because a deleted entity is reported by one id.

Expired positions and paging

A pass spanning runs keeps the service’s next link as its cursor. If the service has forgotten it (410 or 404), or it points at a host the instance no longer uses, the same pass starts again from its first page, and nothing it had read is orphaned; this happens once per pass, and a second restart is refused. An expired delta link, 410, 404 or a code the vendor facet declares (Dataverse 0x80044352), resets the track, and the same run falls back to a full pass. Both are named in the run’s history.

maxRowsPerRun (default 10,000) bounds a run’s work, not its memory, and a run ends on a page boundary. An OData v4 source is asked for pages of at most 1,000 rows with Prefer: odata.maxpagesize, never $top, because $top limits the whole collection and would make a pass look complete after its first page. maxRunDurationMs (default fifteen minutes) bounds how long a run waits on a busy source before it stops as a partial pass, which the next run continues.

Refresh a record as soon as the ERP reports a change Mechanism

Many ERPs can announce a change as it happens: an Odoo automated action, a Dataverse webhook. The announcement reaches the application’s own webhook endpoint, and the code that handles it may name the records that changed. The copy of those records is then re-read from the ERP right away, through the pipeline itself, instead of on the next scheduled pass.

The announcement is treated as a hint, never as data. Nothing it carried is written to a record: the pipeline asks the ERP for the named records and loads the ERP’s answer through its usual mapping, checks and erasure blocks. A forged or replayed announcement can at worst cause a re-read of a record the ERP would serve anyway.

Nothing asked for is lost. The named records are recorded before the re-read is requested, and asking twice for the same record is one request. If a scheduled pass is running at that moment, the request waits and the next run of any kind serves it first. A record the ERP no longer serves is reported, not deleted, since deletion is a full pass’s decision. A re-read that fails never stops the pass that follows it.

Example: A client renamed in the ERP

Someone renames a client account in Odoo, and an automated action posts the account’s id to the application. The application re-reads that one account and the new name appears in the copy, while the hourly pipeline carries on as before.

An Odoo rename sends an Id notification so the application re-reads one account, while the separate hourly pass continues.
For engineers

An inbound handler declares the pipelines it may ask in pipelineKeyRequests and calls ctx.pipelines.requestPipelineKeys. No application declares such a handler yet; the following is the example the engine documents beside the API, in inbound-handler-pipelines.backend.definitions.ts, with the handler’s source, payload schema and roles elided as in the original.

defineInboundHandler({
  ref: 'odoo.partner-changed',
  // …source, payloadSchema, scopeMode: InboundHandlerScopeMode.APPLICATION, actingRoles…
  pipelineKeyRequests: [AppResourceType.ERP_CUSTOMERS],
  handle: async (ctx, payload) => {
    const { malformedKeys } = await ctx.pipelines.requestPipelineKeys(AppResourceType.ERP_CUSTOMERS, payload.ids.map(String));
    // malformedKeys: ids the pipeline's key codec cannot read — log them; the rest are recorded.
  },
});

Keys are the remote keys the pipeline stores in its remoteKeyLocalField. With one key field, the id as the sender spells it is accepted and normalized. A key that does not decode is returned in malformedKeys, never recorded and never thrown about, so one bad id does not make the delivery retry forever. A call for a resource the handler did not declare throws. One call may name at most 1,000 keys; a notification naming more is a bulk change, which a full pass handles.

The handler’s roles do not apply to the re-read, because the handler writes nothing: the pipeline loads what the ERP serves, exactly as a scheduled pass would.

What the re-read does

The door records each key, then asks for a keyed run. A keyed run re-reads exactly those records — Odoo search_read by ids, OData v4 key predicates — within the pipeline’s sourceFilter, and loads them through the same transform, erasure suppressions, reference resolution and lease as a pass. Every inbound run drains waiting requests before its own pass, so a request made while a pass held the pipeline is served by the next run, and a record loaded this way during a long full pass joins that pass’s membership and is never orphaned by it.

A requested record the ERP does not return is counted as missing and its request dropped; the local record stays. A page whose load fails keeps its requests for the next run. On a two-way pipeline, a re-read record is decided exactly as a pass decides it, so a shared field changed on both sides is a conflict under the declared policy.

The run history and the audit trail name the delivery that asked for the re-read and how many keys it named. An operator can also run the keyed re-read directly with passMode: 'keyed' on the run route.

What startup refuses

A handler naming a resource with no pipeline, an outbound pipeline (it reads nothing to re-read), or a pipeline whose dialect has no keyed read is refused at startup. Keyed reads exist for Odoo JSON-RPC with a single key field and for OData v4.

Reshape imported values without writing code Mechanism

A pipeline copies ERP fields into the application’s own records, and the value the application needs is not always the value the ERP holds. A status code must become one of the application’s own statuses, a display name must be built from two fields, an amount must be converted at an agreed rate. These are declared beside the mapping, not written as code.

Besides copying a field, filling a constant and turning an ERP link into a real local link, a mapping can declare four expressions: a code map, a concatenation, arithmetic (multiply, add, round) and a choice made on one field’s value. Each names the ERP fields it reads and the local field it writes. Nothing runs that the declaration does not show, so every derived value can be reviewed in one place.

The declaration is checked when the application starts. An expression pointed at the wrong kind of field, a status that no longer exists, or a result that could be empty where the field does not allow it stops the start, naming the entry, instead of failing on every record of every run. The fields an expression reads are requested from the ERP like any mapped field, so a field the ERP does not have fails the run loudly rather than leaving every record empty.

A record whose value cannot be derived, such as a status code nobody mapped, is rejected on its own and reported, and its previous version stays. Changing an expression makes the next pass re-read every record, so the new rule reaches records that did not change in the ERP.

Example: Show a client's name with its finance reference

Wonder Todos declares a pipeline that copies client accounts from Odoo. Two clients can share a trading name and are told apart only by the reference finance files them under, so the name people see is the client’s name followed by that reference, for example “Acme Industries · C-0042”. A client with no reference shows its name alone, never with a trailing separator.

Name and Reference combine into Acme Industries · C-0042, while a missing reference produces Acme Industries without a trailing separator.
For engineers

The following is the mapping of Wonder Todos’ externalCustomers pipeline, with comments omitted. The enums come from @wildo-ai/saas-models/external-data.

mapping: [
  {
    kind: ExternalDataMappingEntryKind.CONCATENATION,
    localField: 'name',
    parts: [
      { kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'name' },
      { kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'ref' },
    ],
    separator: ' · ',
  },
  { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'email', localField: 'email' },
],

A concatenation needs at least two parts, at least one of them an ERP field, and writes a string. A part whose value is absent or empty, including Odoo’s false, is dropped with its separator. When no ERP part has a value the result is absent and lands as null, or rejects the record when the entry declares required: true.

The other expressions

The engine’s integration test declares an arithmetic conversion and a conditional label like this (excerpt):

{ kind: ExternalDataMappingEntryKind.ARITHMETIC, remoteField: 'amount', localField: 'amountEur', factor: 0.92, decimalPlaces: 2 },
{ kind: ExternalDataMappingEntryKind.CONDITIONAL, remoteField: 'is_company', localField: 'kindLabel',
  cases: [{ equals: true, then: { kind: ExternalDataConditionalOutcomeKind.CONSTANT, constant: 'Company' } }],
  // As the factory resolves it for a string target on an Odoo source.
  otherwise: { kind: ExternalDataConditionalOutcomeKind.REMOTE_FIELD, remoteField: 'function', coercion: ExternalDataFieldCoercion.ODOO_FALSE_AS_NULL } },

A code map, shown here as a design example with an application enum InvoiceStatus, maps every known code and states what an unknown one does:

{
  kind: ExternalDataMappingEntryKind.ENUM_MAP,
  remoteField: 'state',
  localField: 'status',
  values: [
    { remoteValue: 'draft', localValue: InvoiceStatus.DRAFT },
    { remoteValue: 'posted', localValue: InvoiceStatus.POSTED },
  ],
  unmapped: { policy: ExternalDataUnmappedValuePolicy.USE_DEFAULT, localValue: InvoiceStatus.OTHER },
},
KindWritesNotable rules
ENUM_MAPa member of a local enumeach code mapped once; an unknown code rejects the record unless unmapped declares USE_DEFAULT
CONCATENATIONa stringabsent parts dropped with their separator; literals alone are refused
ARITHMETICa numberremote × factor + offset, optional decimalPlaces; a factor of 0, or a copy with no change, is refused; an integer field requires decimalPlaces: 0
CONDITIONALthe field’s typecases compared exactly, first match wins, otherwise required; an outcome is a constant or another ERP field
REMOTE_REFERENCEa local foreign keyresolved after the load, so order and self-references do not matter

An open function slot (transform: (row) => value) is refused: every entry is strict, and an unknown key fails the parse rather than being silently dropped.

A REMOTE_REFERENCE entry reads an Odoo many-to-one or an OData navigation key and writes the local id of the target’s own copy: another pipeline destination, or a read-through resource, on the same system of record. Resolution runs after the load, so a child read before its parent, and a record pointing at itself, both resolve. A target that is not loaded yet leaves the field empty and the reference pending, retried at the end of every later run (LEAVE_PENDING, the default), or rejects the record (REJECT_ROW).

Which pipelines can use them

Expressions derive a local value from ERP values and have no inverse. An inbound pipeline loads them, a two-way pipeline loads them as fields only the ERP owns, and an outbound pipeline cannot send them.

Send records created in the application to the ERP Mechanism

A pipeline can run in the other direction. Instead of copying ERP records into the application, it sends the application’s own records to the ERP: a record created here is created there, and a later edit becomes an update of that same ERP record. The application owns the mapped fields, people edit them in the application, and the ERP’s identifier is written onto the local record by the engine once the ERP answers the create.

A change is noted in the same transaction as the local edit, so nothing changed is forgotten. On its schedule, the pipeline sends one write per changed record, oldest first. Only records of the organization that owns the application are sent, and on an ERP split by company, only records whose unit is assigned to one of its companies.

The send uses the same safeguards as a person’s direct write: an idempotency key per change, placement in the right company, and an audit trail that names the member whose change was sent. A local deletion is never sent. Removing a record from the ERP is that system’s operators’ decision, not the side effect of a local edit.

When a write leaves and its answer is lost, the change is not retried, because a create sent twice could make the record twice. It waits for an operator who has looked in the ERP: they answer that it landed, giving the identifier of the record they found, or that it did not, and only then is it settled or sent again.

Example: Publish new services to the ERP catalogue

A team defines the services it sells in the application. Each new service appears in the company’s Odoo product catalogue on the next pass, in the subsidiary its unit belongs to, and a later price change in the application updates that same Odoo product. Deleting a service in the application leaves the Odoo product in place.

Application services and price updates reach the ERP catalogue on the next pass, while a locally deleted service remains in the ERP.
For engineers

No application declares an outbound pipeline yet, so the following is a design example, checked against the declaration schema and its startup rules. It sends a destination’s name and listPrice to Odoo’s product model. ExternalDataPipelineDirection, ExternalDataMappingEntryKind, HttpApiWriteVerb, HttpApiWriteConcurrencyMode and HttpApiKeyComponentCodec come from @wildo-ai/saas-models/external-data.

externalDataPipeline: {
  direction: ExternalDataPipelineDirection.OUTBOUND,
  binding: {
    dialect: HttpApiTransportDialect.ODOO_JSONRPC,
    system: 'odoo',
    entityRef: 'product.template',
    keyFields: [{ localField: 'id', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
    subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
    writableVerbs: [HttpApiWriteVerb.CREATE, HttpApiWriteVerb.UPDATE],
    writeConcurrency: { mode: HttpApiWriteConcurrencyMode.LAST_WRITER_WINS },
  },
  remoteKeyLocalField: 'odooProductRef',
  partitionUnitLocalField: 'organizationUnitId',
  mapping: [
    { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'name', localField: 'name' },
    { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'list_price', localField: 'listPrice' },
  ],
  scheduleCron: '*/15 * * * *',
},

Each mapping entry now names the local field that is sent and the remote field it lands on. The mapped fields are the application’s, so callers write them. remoteKeyLocalField is empty until the first create is answered; it must be optional, unique, and excluded from create and update, so no caller can point a local row at an arbitrary ERP record. partitionUnitLocalField is required on an ERP split by company: a created record lands in the company assigned to the row’s unit, and that placement never moves afterwards.

What the declaration may and may not say
RuleWhy
writableVerbs must include CREATE; UPDATE is optionala row gets its remote key only from the create that sends it
DELETE is refuseda local delete is never sent
writeConcurrency must be LAST_WRITER_WINSno caller read the remote version; detecting a change on both sides is what a two-way pipeline does
only REMOTE_FIELD and REMOTE_REFERENCE entriesa concatenation, a code map or a constant has no inverse to send
sourceFilter, incremental, archivedRecords, partitionKeyLocalField refusedthey describe reading a source, and an outbound pipeline reads none
required and coercion on an entry refusedthey apply to values loaded from a source
subjectRights.posture may not be FORWARDED_TO_SOURCE or OWNED_COPYthe rows are the application’s own, and the ERP answers requests about the records it received

At startup the engine also refuses a dialect that cannot write (OData v2), a vendor whose writes need a handshake it does not perform (SAP S/4HANA), a mapping that sends the instance’s partition field, and, on a company-split instance, updates over a dialect that cannot read one record’s partition (REST JSON).

What a pass sends

A pass runs under the pipeline’s lease, so it is the only sender for that destination. For each due change it reads the row as it stands now, drops the change when the row is gone, hidden by an erasure, or belongs to another organization, and sends a create when the row has no remote key or an update when it has one. A row whose reference points at a record not yet in the ERP waits for a later pass, or is sent after its parent in the same pass; its create then carries the parent’s ERP identifier. A change that cannot be placed in a company is deferred, reported, and retried after a back-off.

The Idempotency-Key is built from the change, its cycle, its generation and its attempt. A newer local edit is a new request; a retry after a definite refusal is a new request; a retry after an unknown outcome keeps its key, so the ledger answers it and nothing is sent twice. Once the ERP answers success, the write is treated as applied whatever happens afterwards. If recording the remote key fails, the next pass settles the change from the row or the ledger and never turns it back into a pending create.

A change is recorded by every service-tier create and update of the destination, including a custom operation that borrows one of them. The pipeline’s own bookkeeping goes through the repository tier and records nothing, which is why a pass never echoes its own write.

Answer an unknown outcome

The operator routes live under /internal/admin/external-data and require the application’s admin secret:

RouteUse
GET /outbound-changes?resourceType=…list the changes of unknown outcome
POST /outbound-changes/resolveanswer one: answer: 'applied' with the remoteKey found for a create, or answer: 'not-applied'; a reason is required
POST /outbound-changes/unlinkdetach a row from a remote record it holds wrongly, naming both

An applied answer writes the remote key to the row, so later edits reach that record as updates. A not-applied answer sends the change again on the next pass, under a new key. The door refuses, with a stable code, an answer that would link a second row to the same ERP record, re-point a row that already holds another key, or unlink a row whose own send is still in flight. Every answer is audited.

Keep records in step when both sides edit them Mechanism

Some records are edited in two places: the ERP team maintains them in the ERP, and people using the application change them too. A two-way pipeline keeps one copy in the application and moves changes in both directions, field by field, according to who owns each field.

A field owned by the ERP is loaded and read-only in the application. A field owned by the application is sent and never loaded. A field owned by both is loaded and sent. Only a shared field can conflict, so the common case, ERP master data with a few fields of the application’s own, never meets conflict handling at all.

A change in the ERP is recognized by the ERP’s own record version, such as Odoo’s last-updated stamp or an OData entity tag, compared with the version both sides last agreed on, together with a fingerprint of the shared values. When both sides changed a shared field, the pipeline applies the declared rule: the ERP wins, the application wins, or, by default, neither is overwritten and the record waits for a person to choose. Every resolved conflict is audited, with the fields overwritten and whether the declared rule or a person decided it.

The decision is made per field when both sides say which fields they changed: a field only the ERP changed loads, a field only the application changed is sent, and only a field both changed to different values is a conflict. A local edit made while a page of ERP records is being loaded is never overwritten: the load is conditioned on the local version it read, and a record edited in between is set aside and read again. After the application writes to the ERP, it reads the record back and checks, field by field, that it holds what was sent before treating the two sides as agreeing again.

Example: A price changed in two places

Sales updates a service’s list price in the application while finance changes the same price in the ERP. On the next run the pipeline sees both changes. Under the default rule it overwrites neither, lists the service as a conflict on every run summary, and keeps loading the product name, which only the ERP owns. An operator chooses the finance price; the next run loads it, and the choice is recorded.

Simultaneous list-price edits meet as a conflict for an operator to decide, while the product name flows only from ERP to application.
For engineers

No application declares a two-way pipeline yet, so the following is a design example, checked against the declaration schema. The product name belongs to Odoo, the list price to both sides, and a sales note to the application. ExternalDataFieldOwner and the other pipeline enums come from @wildo-ai/saas-models/external-data; ExternalDataConflictPolicy from @wildo-ai/saas-models.

externalDataPipeline: {
  direction: ExternalDataPipelineDirection.BIDIRECTIONAL,
  binding: {
    dialect: HttpApiTransportDialect.ODOO_JSONRPC,
    system: 'odoo',
    entityRef: 'product.template',
    keyFields: [{ localField: 'id', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
    subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
    writableVerbs: [HttpApiWriteVerb.UPDATE],
    writeConcurrency: { mode: HttpApiWriteConcurrencyMode.LAST_WRITER_WINS },
  },
  remoteKeyLocalField: 'odooProductRef',
  partitionUnitLocalField: 'organizationUnitId',
  partitionKeyLocalField: 'odooCompanyRef',
  mapping: [
    { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'name', localField: 'name' },
    { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'list_price', localField: 'listPrice', owner: ExternalDataFieldOwner.BOTH },
    { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'description_sale', localField: 'salesNote', owner: ExternalDataFieldOwner.LOCAL },
  ],
  conflictPolicy: ExternalDataConflictPolicy.REFUSE_AND_REPORT,
  scheduleCron: '*/10 * * * *',
},

owner is accepted only on a two-way pipeline and defaults to REMOTE. The binding must open UPDATE; opening CREATE as well sends records born in the application, and then population: OPEN is required. writeConcurrency is LAST_WRITER_WINS because the pipeline checks every update against the version it last agreed with the ERP, not against one a caller read; a change on both sides is the conflict policy’s to decide. Declared expressions may only be REMOTE-owned, since they have no inverse to send. The pipeline reads and loads like an inbound one, so it takes sourceFilter, incremental and both partition fields as an inbound pipeline does.

How a run decides each record

A run loads first and sends second, under one lease, sharing the run’s deadline so that a long load still leaves the sending half time.

Changed in the applicationChanged in the ERPThis run
noeitherloads the ERP’s values and records the version as agreed
yesnokeeps the local values; the sending half sends them
yesyesa conflict, decided by the policy or by an operator’s choice for that record

Four cases stand outside the table. A record served with no version cannot be compared, so the local values stand and the record is counted as unversioned. A new ERP record that could be the result of a create whose outcome is unknown is held rather than loaded, to avoid a duplicate. A record whose own send is in flight decides nothing. And when a write of the application’s landed without its version being agreed, a conflict on that record goes to an operator even under REMOTE_WINS, because loading could overwrite a newer local edit with the application’s own older values.

Conflict policies and the operator door
conflictPolicyWhat happens to the shared fields
REMOTE_WINSthe ERP’s values load; the local change to them is discarded; audited
LOCAL_WINSthe local values stand and are sent over the ERP’s; audited
REFUSE_AND_REPORT (default)nothing is overwritten; the record is listed on every run summary until an operator chooses

The operator door lists refused conflicts with GET /internal/admin/external-data/outbound-changes/conflicts?resourceType=… and answers one with POST /internal/admin/external-data/outbound-changes/conflicts/resolve, naming the record (localResourceId), the winner ('remote' or 'local') and a required reason. The choice is applied by the next run and audited as decided by an operator. Fields owned only by the ERP keep loading whatever the policy, because nothing local writes them.

After the application writes

The version read after a write and the write itself are two calls. The new version is adopted as agreed only if the record’s shared fields are exactly as the write left them; anything else is treated as a change made in the ERP in that instant, and the next run decides it. A value the ERP stores differently from what was sent, or a side effect of the write on another shared field, also withholds agreement. That costs a reload or an operator’s decision, the direction that never loses an edit made in the ERP.

Operate it and answer for the data

Watch and steer data pipelines in production Feature

A pipeline that copies or sends ERP records runs on its own schedule, against a system nobody on the team controls. Operators need to know whether it is keeping up, what it refused, and how to move it on when it stops. Every run writes a history entry: how it ended, why it failed if it did, and the ERP records it rejected, each with its identifier and the reason. From that history the pipeline’s health is derived: when it last completed a pass, when it last completed a full one, and how many runs in a row have failed.

People reading the copy are told where a value comes from. A field a pipeline wrote shows a short note beneath it, such as “Synced from Odoo · as of 12 minutes ago”. The time is the start of the most recent completed pass, so the note describes how current the copy is as a whole; before any pass completes it says nothing.

Operators act through a small set of protected routes: run a pipeline now, read its history, restart a pass it refuses to continue, and answer the cases it holds for a person — a write of unknown outcome, a two-way conflict, a record linked to the wrong ERP record, erasure blocks whose source changed. Each answer requires a written reason and is recorded in the audit trail. Developers can read the same listing and history from the command line.

The pipelines also look after themselves. When the ERP cannot be reached, scheduled runs back off instead of repeating the same warning every hour, and the first run that gets through says so. When the ERP is busy, a run waits within a declared budget and stops cleanly at its deadline, keeping what it loaded. Only one run of a pipeline works at a time across all the application’s servers.

Example: A nightly pass that rejected three records

An operator sees that last night’s pass completed but rejected three client records. The run history shows each one’s ERP identifier and the reason: a status code the declaration does not map. They add the code to the mapping, deploy, and run the pipeline now rather than waiting for the next scheduled pass.

An operator reviews a completed pipeline run with three rejected records and their reasons, then can run it now.
For engineers

The routes are mounted at /internal/admin/external-data, outside the application’s API, and require the X-Admin-Secret header to match the application’s APPLICATION_ADMIN_SECRET. When that secret is not set, every route answers 503, so a fresh deployment never exposes a door into a third party’s system by accident.

RouteWhat it does
GET /pipelinesevery declared pipeline with its cursor, incremental track, last-run summary, unreachable streak and health
POST /runrun one pipeline now; passMode may force full, incremental or keyed
GET /runsone pipeline’s run history, newest first, with rejected rows (at most 100 runs)
POST /resetdiscard a refused pass: restart-pass, or full-resync, which also drops the incremental watermark; reason required
POST /erasure-suppressions/reconcilecarry over or release erasure blocks filed under a source identity the pipeline no longer has; reason required
GET /outbound-changes, POST /outbound-changes/resolvelist and answer sends of unknown outcome
POST /outbound-changes/unlinkdetach a row from an ERP record it holds wrongly
GET /outbound-changes/conflicts, POST /outbound-changes/conflicts/resolvelist and decide refused two-way conflicts

For example, to force a full pass:

curl -X POST "$BACKEND_URL/internal/admin/external-data/run" \
  -H "X-Admin-Secret: $APPLICATION_ADMIN_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "resourceType": "externalCustomers", "passMode": "full" }'

A run is single-flight per pipeline, so a second request while one is running answers already-running rather than starting another. A run started through this door goes ahead even while scheduled runs are backing off from an unreachable ERP: a person asking now is the best evidence there is.

A reset writes two audit rows under one attempt id: one before anything is discarded, naming what will be, and one when the attempt ends, saying whether it completed, failed before or after discarding, or ended with an unknown outcome.

Read it from the command line
wildo context pipelines
wildo context pipelines --runs externalCustomers --limit 20

The first lists each pipeline with its system of record, its last completed pass and full pass, how long ago that was, and whether it is failing. The second prints one pipeline’s runs with their failure kinds and rejected rows. The command asks the locally running backend with the admin secret from the local environment’s secrets, issues only reads, and refuses a remote environment.

Show where a value came from

The cue needs no declaration. The display chrome asks, for each field, whether a pipeline writes it: the remote key, every mapping entry that reads an ERP field, and the partition stamps, but not a constant and not a field the application owns. For those fields it reads GET …/<resource>/external-data-provenance once and shows the instance’s display name, from the systems_of_record.<name>.displayName label, with the time. The note appears where one record is read and is left out of table cells and cards. An outbound pipeline’s fields carry no cue, since they are the application’s own.

When the ERP is down, busy or has changed

Only transport failures count as unreachable: a timeout, a network failure, a transient status. A 401 or a malformed page is a real answer and is reported at once. After the first failure, the run logs one warning and records the streak on the pipeline’s state; scheduled ticks then skip 1, 3, 7, 15… occurrences of the schedule, never more than a day apart and never sooner than a Retry-After the ERP named. Each later failed attempt logs one summary line, escalating to an error from the third.

A busy ERP is paced within the run: when the binding’s maxRequestsPerMinute is spent, or the ERP answers 429 or 503 with Retry-After, the run waits, up to its maxRunDurationMs deadline, and then ends as a partial pass that the next run continues.

On an OData source, every run first compares the fields it will request with the service’s $metadata and logs a warning for each one the service no longer declares; a missing partition column is called out as a confinement risk. The probe never fails a run.

One runner at a time

A run holds a lease on the pipeline, stored in the application’s database and renewed while it works, so two servers never run the same pipeline at once. Every bookkeeping write a run makes, from loaded rows and checkpoints to agreed versions and change markers, commits in the same transaction as a check that the lease is still this run’s. A run paused long enough to lose its lease therefore cannot overwrite what the next run has done.

Keep an erased person from being copied back in Guarantee

When the application holds a copy of ERP records and erases a person, the ERP still holds the original. Without a safeguard, the next sync would find the record and write it back: the erasure receipt would say complete while the personal data returned within one schedule period.

So when the application keeps a copy itself, an erasure that reaches a copied record also files a block against the ERP record it came from. A copy that only mirrors the ERP is different: the ERP answers requests about its records, so an erasure leaves that copy to it, and the copy removes what the ERP removes at its next complete pass. Every later pass, full or incremental, skips a blocked record and counts the skip in its report and its audit row. The block applies to every pipeline that reads the same ERP records, so two copies of one person are both stopped.

The block never holds the ERP identifier. It stores a keyed fingerprint of it, computed with a secret derived from the application’s own, so an identifier that is a small number cannot be recovered by trying every number. A block is lifted only when a complete pass over the whole ERP entity no longer finds the record: there is then nothing left to copy back. A lifted block is kept as evidence for the audit-log retention period, then removed; a record erased again is blocked again.

The engine refuses rather than guess. If the secret that made the fingerprints changes, pipelines with active blocks stop instead of copying people back. If a pipeline’s source changes identity, by a renamed connection, another entity or another key format, it stops until an operator decides, with a written reason, whether its blocks carry over or are released.

Example: A former client contact asks to be erased

A client contact asks to be erased. Their record in the application is a copy of an Odoo record. The local copy is erased and blocked, and the erasure receipt names Odoo as where requests about the original are handled; the next hourly sync skips the copy and reports one suppressed record.

The Odoo original remains while an erased local copy is blocked from re-import; the next sync skips it and reports one suppressed record.
For engineers

A system of record decides what happens to its records’ personal data and answers the people they concern; the application follows it, and owns only what it keeps beyond or apart from the source. A binding states this once, in subjectRights, and the default needs no declaration at all. Which postures a binding may take depends on how it uses the ERP.

PostureRead-through resourceInbound pipelineOutbound or two-way pipeline
HANDLED_AT_SOURCE (default)the erasure receipt names the system of record as where requests about its rows are handledthe copy follows the ERP: an erasure leaves it, nothing is blocked, and its orphan policy (DELETE or IMPERSONALIZE, required once it copies personal data) removes what the ERP removesallowed: the ERP answers for what it received; outbound rows are the application’s own
NO_SUBJECT_DATAallowed; refused beside any field declared as personal dataallowed; refused beside any copied field declared as personal dataallowed on the same terms
OWNED_COPYrefused: a read-through keeps no copythe application keeps the copy and erases it itself: scrub or delete, and a block; required when the copy holds a personal field the ERP does notrefused
FORWARDED_TO_SOURCEone declared operation the engine also calls for the rows linked to the erased person, best effortrefused: nothing sends a delete through a pipelinerefused

The following is the binding of Wonder Todos’ externalCustomers pipeline, without its key and comments. Its name and email fields declare identity and contact categories, so NO_SUBJECT_DATA would be refused, and the copy keeps internalNote, the team’s own note, which Odoo does not hold, so it is an owned copy:

binding: {
  dialect: HttpApiTransportDialect.ODOO_JSONRPC,
  system: 'odoo',
  entityRef: 'res.partner',
  subjectRights: { posture: SystemOfRecordSubjectRightsPosture.OWNED_COPY },
  purposeRefs: ['task-planning-service'],
},

An erasure whose subject reaches such a copy scrubs or deletes the local rows and blocks their records; its receipt also records that Odoo answers requests about the records the copy came from. No acknowledgement is asked for, under any posture.

Where the block is written, and when it lets go

The block is written at the two places an erasure changes a copied row: the scrub of a row kept under retain-and-impersonalize, and the delete of a row removed as a dependent of the erased subject. When the erasure and the block live in different databases, the block commits first, so a failed erasure leaves the block in place rather than risking a re-import; the unscrubbed row is then reported as a live suppressed copy until the erasure is retried.

A block identifies [system of record, remote entity, key shape, remote key]. It is released only by a completed full pass over the whole entity, with no source filter, that did not see the key. A pass that ran under a filter, or a pass spread over several runs that saw the key in an earlier run, releases nothing. A record that a pass wrote back because an erasure committed during its load is detected and counted as a live suppressed row, never silently skipped forever.

When the source identity changes

Renaming the system-of-record connection, moving a binding to another entity or API version, or changing its key codec changes the identity the blocks were filed under. The digests cannot be recomputed, because that would need the plaintext keys the list exists never to hold, so the pipeline refuses to run until an operator calls POST /internal/admin/external-data/erasure-suppressions/reconcile with the resourceType, an action and a reason:

actionWhen it is right
carry-overthe same records keep the same keys under the new identity, such as a renamed connection or a new API version
releasethe identity no longer names the same records, such as changed key values; recorded at high risk

The decision is the pipeline’s alone; another pipeline reading the same entity is not affected.

Name the ERP as a data source in the privacy notice Guarantee

Personal data an application reads from the company’s ERP or CRM was not collected from the person it describes. The privacy notice then owes them more: where the data came from, what kinds of data, for what purpose and on what legal basis, and whether any of it came from publicly accessible sources.

Wildo derives that statement from the application’s declarations instead of leaving it to be written by hand. For every record type that is read live from, or copied from, a declared system of record, the notice draft receives an entry naming the connection and its vendor, the kinds of personal data its fields carry, and the purposes the binding links, with their legal basis. For a copy, the entry also says whether it is kept until erased or governed by a retention period.

Nothing in it is guessed. A personal field with no declared category is named as such, so an incomplete list says so. A purpose the binding does not link is not stated, and a link to a purpose that does not exist is reported as broken. Whether the data came from publicly accessible sources is the operator’s declaration per connection; when it is missing, the notice marks that point as owed rather than assuming “no”.

A connection is kept apart from the list of recipients given to a person who asks what was shared about them. That list names the service providers the application sends data to; a system of record is the company’s own deployment, and it answers requests about its own records, as each binding’s subject-rights posture states.

Example: Client contacts copied from the ERP

An application copies client account names and email addresses from the company’s Odoo to attribute work to clients. Its privacy notice draft names Odoo as the source of identity and contact data, for the service purpose and its contractual basis, states that none of it came from a public source, and says the copy is kept until erased unless a retention period is declared for it.

A privacy notice names Odoo as the source of identity and contact data, states the purpose, and says it is not from a public source.
For engineers

Three declarations feed the entry; none of them is written for the notice. From Wonder Todos, comments omitted: the instance in wildo.saas.config.ts,

systemsOfRecord: {
  odoo: {
    vendor: 'odoo',
    dialect: HttpApiTransportDialect.ODOO_JSONRPC,
    partition: true,
    personalDataOrigin: SystemOfRecordPersonalDataOrigin.NO_PUBLICLY_ACCESSIBLE_SOURCES,
  },
},

the purpose linked on the pipeline’s binding in external-customers.resources-config.ts, which names a purposeRef of operatorComplianceIdentity.lawfulBases,

subjectRights: { posture: SystemOfRecordSubjectRightsPosture.OWNED_COPY },
purposeRefs: ['task-planning-service'],

and the categories on the destination’s fields in external-customers.schemas.ts:

name: z.string().optional().isSummaryField().excludeFromCreate().excludeFromUpdate()
  .dataCategories(PersonalDataCategory.IDENTITY),

personalDataOrigin is NO_PUBLICLY_ACCESSIBLE_SOURCES or INCLUDES_PUBLICLY_ACCESSIBLE_SOURCES. There is deliberately no default: an ERP contact may have been typed in from a public register, which nothing in the binding can tell.

What the fact contains

Each entry of the system-of-record-sources fact describes one resource and one flow:

FieldFrom
system, vendorthe instance the binding names, and its declared vendor
flowKind, copiedLocallya read-through binding (nothing kept locally) or a pipeline (a local copy)
categories, uncategorizedPersonalFieldsthe .dataCategories() of the fields that come from the system; for a pipeline, only the mapped fields, constants excluded
purposes, purposeRefsNamingNoPurposebinding.purposeRefs resolved against the declared lawful bases
keptUntilErased, retentionClassfor a copy: no retention class and no expiry, or the class it is bound to
personalDataOriginthe instance’s declaration, absent when it states none

A flow that carries no personal data is left out. An application that declares no system of record gets an empty list, which is a true statement. One whose flows could not be read gets no answer at all, rather than an empty list that would claim nothing is obtained.

The same derivation feeds the evidence collector, so what the notice states and what the evidence measures cannot disagree. The privacy notice’s data-and-sources clause cites the fact, and the companion computes it from the application’s materialized resources when a notice is drafted.

What it does not do

It does not decide a purpose: a flow with no linked purpose states none. It does not add the ERP to the recipients a person is told about in an export or an erasure receipt. In the processing record, the instance is listed as a processor holding the categories its flows carry.

Exchange information with the systems around you

Send application events to customer endpoints, receive provider callbacks, and exchange records with external systems. Each direction has its own contract: what is sent, who receives it and how the result is handled.

Wildo supplies shared delivery and external-data mechanisms. You choose the business events, destinations and data ownership, then configure the provider access and receiving systems. A company’s own ERP and CRM have their own section, Systems of record, which compares what each vendor supports.

The application sends customer events, receives provider callbacks and reads external records through distinct paths.

Connect the workflow, not just the request

Make outgoing events actionable

Declare the business actions that notify customer systems. Signed deliveries, attempt counts and an endpoint probe help administrators understand what happened.

Give incoming events a known path

Provider declarations connect callback routes to verification and capability handlers. The event reaches the integration with its expected account context.

Choose how external data is used

Read records on demand, change them where they live, or keep a local copy current in either direction. Each mapped field’s owner is declared, and the fields the application adds beside them are never overwritten by a refresh.

Example: Keep an account workflow connected

An application synchronizes company records from Odoo and adds local notes. A declared task action notifies the company’s endpoint. Separately, a billing provider sends its own verified callback. These are connected business flows with different delivery contracts.

For engineers

Match the mechanism to the work

NeedMechanismWhat the application authors
Notify customer systemsOutbound machine notificationsResource event channel, scoped enabled endpoints and receiver behavior
Receive vendor eventsProvider webhook originatorEnabled protocol, secret configuration and corresponding handler integration
Read external recordsHTTP API binding to a declared system of recordSystem of record, entity, key and subject-rights posture
Change external records where they liveWrite verbs opened on that bindingWhich verbs, the concurrency stance and the roles allowed
Run an operation in the external systemDeclared action on the bindingThe vendor call, its input, roles and idempotency
Keep a local external-data copyInbound pipeline on a local resourceMapping, schedule, population and orphan policy
Send local records to an external systemOutbound pipelineWhich verbs are sent; records that hold no personal data
Edit one record on both sidesTwo-way pipelineEach field’s owner and the conflict policy

An outbound customer webhook is signed with the application’s key and follows the delivery worker’s retry contract. An inbound provider webhook follows that provider’s verification scheme and registered adapter. Sharing the word webhook does not make their signatures or payloads interchangeable.

Follow the result beyond the HTTP call

Each destination endpoint gets one delivery record. Retries update its attempt count, timing and latest outcome. The receiver verifies the raw bytes and deduplicates by delivery identity. An endpoint probe checks the same transport with synthetic data, but acceptance only proves signature verification when the receiver actually performs it.

For external data, decide whether local capability or immediate remote reads matter more. Pipeline destinations can retain local fields and files, but their data is as fresh as the last successful reconciliation. A pass that spans several runs keeps the identities it has seen across them, and only a completed pass can conclude that a record disappeared; keep reporting rather than automatic orphan deletion until the population and its filter are understood. A write to an external record is sent once and never retried by the engine, so an interrupted one is reported as an unknown outcome.

Which vendors accept writes, actions, changed-only passes and two-way pipelines differs. The Systems of record section covers each vendor and the mechanisms in depth.

Configure the path that actually sends

Transactional SMS has a provider-backed service used by authentication flows. Declarative SMS notifications and mobile push do not currently have a general sending implementation. Select an implemented channel for the product behavior you need today; the capability entries below keep those boundaries explicit.

Outbound target checks are applied by their respective transports. The webhook URL classifier rejects reserved literal targets, and its transport refuses redirects; the client-metadata fetcher separately uses a DNS-pinned transport. Keep network egress and endpoint verification aligned with the actual integration path.

Send events to other systems

Let business actions notify other systems Mechanism

A business action can notify systems outside the application as part of its declared behavior. Wildo turns that notification into queued deliveries for the enabled endpoints in the relevant scope.

The application chooses the actions and channels. Endpoint configuration and delivery handling remain separate, so adding a destination does not require editing the business operation.

Example: Tell a customer’s system when work changes

A task operation emits an organisation webhook notification. The organisation’s enabled endpoints receive queued deliveries of that event; endpoints belonging to another organisation are not selected.

A task event fans out to enabled endpoints in the relevant organisation.
For engineers

This selected notification block comes from Wonder Todos’ Todos resource configuration. It shows user-facing and machine-facing notifications beside each other inside an operation:

      userNotifications: [
        { target: CoreUserNotificationTarget.USER_SELF, channel: CoreUserNotificationChannel.FRONT_END_SUCCESS },
        { target: CoreUserNotificationTarget.ORGANIZATION_USERS, channel: CoreUserNotificationChannel.WEBSOCKET },
      ],
      m2mNotifications: [{
        channel : CoreM2MNotificationChannel.WEBHOOK_ORGANIZATION, level : M2MNotificationLevel.INFO,
      }]
    },

Register the resource configuration through the shared module as usual. WEBHOOK_ORGANIZATION resolves the organisation from execution context; WEBHOOK_APPLICATION selects application-level configuration. These are different audiences. A custom channel string is vocabulary, not a sender implementation: the corresponding dispatch path must exist.

Connect declaration to actual delivery

M2MWebhookDispatcherBackendService resolves the scope, checks the feature and enabled webhook configuration, then filters enabled endpoints. For the organisation channel it requires a valid organisation identity instead of falling back to application scope. The operation result is serialized through the resource response serializer before fan-out.

The dispatcher serializes the body once, creates a delivery-log row per enabled endpoint and queues each delivery. The worker owns signing, HTTP attempts and retry classification. That separation keeps the business action from waiting for the receiver’s network response.

ResponsibilityOwner
Which action emits an eventResource operation declaration
Which organisation receives itExecution context and scoped webhook configuration
Which URLs receive itEnabled endpoint entries
Retries and delivery statusDelivery worker and log
What the receiver doesCustomer integration

Enable and place the webhook administration surface and configure the signing infrastructure and worker queue. An operation declaration alone does not create endpoints. If queue submission fails after a row exists, the row remains pending for an administrator retry; do not interpret operation success as receiver acknowledgement.

Continue with signed customer deliveries for the receiver contract and endpoint testing for the diagnostic path.

Deliver verifiable events to customer endpoints Feature

Customers can register endpoints that receive events from the application. Wildo signs each delivery, records its outcome and retries failures according to a shared delivery policy.

The receiving system can verify where a message came from, check the payload bytes and recognize repeated attempts. Administrators can inspect each endpoint delivery’s attempt count and latest outcome, then retry a delivery that needs attention.

Example: Process one event despite a retry

A receiver accepts an event but its response is lost. When delivery is attempted again, the same delivery identifier lets the receiver recognize the event and avoid repeating its business effect.

The receiver verifies a signed event, deduplicates its delivery identity and then handles it.
For engineers

Create the scoped webhook configuration, enable it and add enabled HTTPS endpoints. Resource operations must emit the matching machine-notification channel. Signing keys and the delivery worker must be available; putting Webhooks in the Settings Hub only exposes administration, not the backend prerequisites.

A delivery has a stable ID across attempts. The worker signs a fresh token for each attempt and sends it in x-wildo-webhook-signature. Read the current applicationPublicKeyPem from the webhook configuration; a rotation changes the verification key, so do not hardcode a deployment-specific key identifier.

This selected contract from m2m-webhook-delivery-contract.shared.ts states the signed claim settings and payload hash algorithm:

  signature: {
    issuer: APPLICATION_JWT_ISSUER,
    audience: 'WEBHOOK',
    expiresIn: '6h',
  },
  /** Algorithm of the `bodyHash` claim: a hex digest of the exact raw request bytes. */
  bodyHashAlgorithm: 'sha256',
  /** Status codes (besides 5xx and transport failures) that schedule another attempt. */
  retryableHttpStatuses: [408, 429],

Verify the signature with the application’s key while pinning ES256, the expected issuer and audience. Check expiry, recompute SHA-256 over the exact raw request bytes and compare the signed bodyHash. Only then deserialize and act. Use jti as the delivery deduplication key; storing successful processing atomically with the business effect is the receiver’s responsibility.

This Node.js receiver example uses jsonwebtoken. Pass a Headers object, the unmodified request body as a Buffer, and the public key obtained from the trusted application configuration. Keep body-parser middleware from replacing those bytes with reserialized JSON.

import jwt from 'jsonwebtoken';
import { createHash } from 'node:crypto';

function verifyDelivery(headers, rawBody, applicationPublicKeyPem) {
  const token = headers.get('x-wildo-webhook-signature');
  if (!token) throw new Error('Missing webhook signature');

  const claims = jwt.verify(token, applicationPublicKeyPem, {
    algorithms: ['ES256'],
    issuer: 'wildo-application',
    audience: 'WEBHOOK',
  });

  const bodyHash = createHash('sha256').update(rawBody).digest('hex');
  if (typeof claims !== 'object' || claims.bodyHashAlg !== 'sha256'
      || claims.bodyHash !== bodyHash || !Number.isInteger(claims.exp)
      || typeof claims.jti !== 'string' || !claims.jti) {
    throw new Error('Invalid webhook claims or body');
  }
  return { deliveryId: claims.jti, synthetic: claims.synthetic === true, event: JSON.parse(rawBody.toString('utf8')) };
}

Only call the business handler after this succeeds. If synthetic is true, acknowledge the verified probe without applying a business effect. Otherwise, store deliveryId with the business effect in one transaction; acknowledge an already processed ID without applying the effect again. A retry carries the same ID with a freshly signed token. This helper verifies a delivery; the receiver’s transaction and HTTP acknowledgement remain application-specific.

Understand acknowledgements and retries

The delivery contract allows five total attempts, with retry delays of one minute, five minutes, thirty minutes and two hours. A 2xx response completes delivery. Transport failures, 5xx, 408 and 429 are retryable; redirects are reported rather than followed. The request timeout is thirty seconds and captured response text is bounded.

Receiver responseDelivery consequence
2xxAccepted by the receiver
408, 429 or 5xxRetry according to the remaining attempt budget
Redirect or other non-retryable 4xxTerminal classification rather than automatic redirection
Connection failureRecorded transport failure and retry policy

One delivery record is created for each destination endpoint. Retries update that record’s attempt count, last and next attempt times, and latest response or failure; they do not create a separate response history for every attempt. Administrative retry requeues the delivery, and date-bounded cleanup removes eligible terminal records. A successful delivery establishes receiver acknowledgement, not proof that the receiver completed its own downstream workflow.

Use HTTPS. The current endpoint contract also admits HTTP, but a signature does not encrypt payloads in transit. The outbound-target guard and redirect policy apply to this path; request safety explains their exact boundary.

Check an endpoint before relying on it Tool

An endpoint test sends a synthetic signed request through the same transport used for event delivery. It tells an administrator whether the endpoint accepted it, refused it, could not be reached or was never sent.

That distinction helps locate the next action: fix the receiver, the address or the application’s signing configuration.

Example: Find a wrong endpoint path

An administrator tests a new endpoint and receives a rejection response. The request reached a server, so the next step is to inspect its path or verification response rather than treating it as a connection outage.

A signed test reaches an endpoint and reports its rejection for diagnosis.
For engineers

The webhookConfig.testEndpoint operation accepts an endpointId in its request body, uses the stored endpoint and the application’s signing key. It is an update-like operation because it sends real traffic, even though it creates no delivery-log row. It shares sendSignedWebhookRequest with the delivery worker, so timeout, signature header, URL guard and redirect behavior are exercised on the same path.

This is a real outbound request with synthetic claims. The receiver should identify the probe and verify it without treating it as an ordinary business event. It does not create a delivery-log row; the operation records an audit event separately.

The request and response below come from webhook-config.shared.resources-config.schemas.ts, with documentation comments omitted. Send the stored endpoint’s ID in the operation body; the returned URL confirms which destination was tested. The diagnostic includes both the transport verdict and details for correlating the attempt with the receiver.

export const WebhookConfig_TestEndpointRequestDto = z.object({
  endpointId: z.string().min(1),
});

export const WebhookConfig_TestEndpointResponseDto = z.object({
  endpointId: z.string(),
  url: z.string(),
  transportIsPlaintext: z.boolean(),
  outcome: z.enum(WebhookEndpointProbeOutcome),
  responseStatus: z.number().int().optional(),
  responseBodyTruncated: z.string().optional(),
  failureReason: z.string().optional(),
  durationMs: z.number().int().nonnegative(),
  signatureJti: z.string(),
  attemptedAt: z.date(),
});

Read the diagnostic outcome, not only the API status: a successfully executed probe may return HTTP 200 with UNREACHABLE. Unknown endpoint IDs, missing configuration and rate-limit refusal are request errors.

ACCEPTED means the endpoint returned 2xx. It only supports confidence in signature verification when that receiver actually verifies before responding; a server returning 200 unconditionally proves reachability, not verification. REJECTED retains a response status; UNREACHABLE covers transport failures; NOT_SENT separates a local signing problem.

Acknowledge a verified probe without running business work

Use the receiver verification shown in signed webhook delivery. It verifies the signature and raw-body hash before returning the authenticated synthetic claim. This illustrative handler fragment uses that verifyDelivery helper; acknowledge sends the HTTP response and handleBusinessEventOnce is the receiver’s own durable deduplication and business handler.

const delivery = verifyDelivery(headers, rawBody, applicationPublicKeyPem);
if (delivery.synthetic) {
  acknowledge(204);
  return;
}

await handleBusinessEventOnce(delivery.deliveryId, delivery.event);
acknowledge(204);

Do not branch on an unverified body field or decoded JWT. A failed verification must not receive a successful acknowledgment. The genuine event path must persist its delivery identity with its business effect; the probe path deliberately performs neither business work nor ordinary delivery processing.

Make the result actionable

Register and expose the webhook configuration’s normal administration operations. The test is rate-limited, and its limiter fails closed if unavailable. Do not add a second generic HTTP test button: it could pass while the actual signing or delivery path fails.

The probe is a diagnostic, not a guarantee of future delivery. After acceptance, emit a real configured resource event and inspect the delivery log and receiver behavior. Keep endpoint test handlers free of unintended business side effects and use HTTPS endpoints just as for production deliveries.

Check destinations before sending requests Guarantee

When an application sends a request to a supplied address, that address needs more than URL validation. Wildo provides shared checks for embedded credentials, reserved hostnames and private or reserved literal addresses.

The webhook delivery path checks at send time and does not follow redirects. This protects stored endpoints as well as newly entered ones; the exact DNS protection depends on the caller’s transport.

Example: Reject an internal-address webhook target

A customer supplies a loopback address as a webhook endpoint. The delivery path refuses it before issuing the request, and the endpoint test uses that same decision.

URL checks refuse a loopback target before a webhook request is sent.
For engineers

classifyOutboundTargetUrl checks admitted schemes, credentials in the URL, special-use hostnames and literal IP ranges. In the webhook transport the check happens immediately before I/O, not only when the endpoint is saved:

  const refusal = refuseUnsafeWebhookTarget(params.url);
  if (refusal) {
    return { networkError: refusal, durationMs: Date.now() - startedAt };
  }

  const abort = new AbortController();
  const timer = setTimeout(() => abort.abort(), REQUEST_TIMEOUT_MS);

This selected fragment comes from m2m-webhook-signed-request.backend.utils.ts. A refusal is returned as a transport result; the worker and probe then report it according to their own outcome contracts. The same transport uses redirect: 'manual', so a redirect cannot silently move delivery to a different address.

Distinguish the protection levels
MechanismWhat it checks
URL classifierScheme, embedded credentials, literal addresses and special-use names
DNS-resolving checkAddresses returned for a hostname before connection
Pinned outbound fetchAddress classification inside the connection lookup path

These are not interchangeable. The webhook path currently uses the URL classifier and ordinary fetch; an ordinary hostname resolving to a private address is outside that classifier’s protection. A preflight DNS check alone also leaves a change-between-check-and-connect problem. The client-metadata fetcher uses pinnedOutboundFetch to close that gap on its own path.

Do not claim that every outbound request in the framework has identical protection. When implementing a new server-side callback or metadata reader, use the transport suitable for its trust boundary and permitted schemes, and preserve redirect and timeout handling. Network egress policy remains a deployment responsibility.

The classifier does not establish reachability or endpoint ownership. It answers whether the supplied target is disallowed by its checks. Use the endpoint probe to investigate a webhook’s response, without interpreting acceptance as a general network-safety audit.

Receive events and read external records

Receive provider events through their declared integration Mechanism

Providers can notify the application when something changes in their system. Wildo registers inbound routes from an enabled provider’s webhook declaration and sends verified events to the handler that owns them. Stripe declares one for billing events, and Resend one for email the application receives.

This is different from sending your application’s events to customer endpoints. The provider defines the incoming event format and verification contract; the application enables and configures the integration.

Example: Receive a billing update

A billing provider sends a subscription event to the application. The registered webhook path verifies the provider’s request and resolves the billing account context before dispatching the update.

A provider callback is verified and associated with its billing account before processing.
For engineers

Stripe declares its billing API protocol separately from WEBHOOK_ORIGINATOR. Enable the protocols required by the integration and supply STRIPE_WEBHOOK_SECRET as backend secret configuration. Register the endpoint with the provider using the actual deployed API address.

This selected protocol entry comes from stripe.backend.module.ts. Documentation comments are omitted; the provider’s other protocols and module registration remain outside this excerpt.

protocol: "WEBHOOK_ORIGINATOR",
runtimeContract: {
  kind: ProviderProtocolRuntimeKind.WEBHOOK_ORIGINATOR,
  secretRef: "STRIPE_WEBHOOK_SECRET",
  signatureHeaderName: "stripe-signature",
  endpoints: [
    {
      key: 'billing',
      capability: BUILTIN_PROVIDER_CAPABILITY.BILLING,
      signatureConfig: {
        algorithm: Hook_SignatureAlgorithm.HMAC_SHA256,
      },
      triggerStrategy: Hook_TriggerStrategy.WEBHOOK,
      eventTypes: [...STRIPE_BILLING_EVENT_TYPES],
      scopeBinding: {
        mode: 'polymorphic',
        payloadScopeIdPath: 'data.object.customer',
        lookupField: 'providerCustomerId',
        variants: STRIPE_BILLING_ACCOUNT_WEBHOOK_VARIANTS,
      },
    },
  ],
},

The highlighted scope binding matches data.object.customer through providerCustomerId to the configured billing-account variants. The endpoint key supplies the route segment; its capability selects the registered billing handler. Do not infer the tenant from an unverified arbitrary request parameter.

Register a complete receiver, not merely a route name

WebhookAutoController discovers enabled provider endpoint declarations. Before mounting a route it checks that something can dispatch the event, a capability handler or an engine handler the endpoint is bound to, and that something can verify it, the provider’s adapter or the engine itself. An incomplete combination is withheld and reported rather than producing a route that can never dispatch.

The request path preserves raw body bytes for signature verification. Stripe’s adapter verifies its HMAC signature and the billing handler processes the event, with idempotency handling in that pipeline. Resend’s endpoint declares the Svix scheme, which the engine verifies without a provider adapter; each notification is stored once, answered, and processed by a worker that retries it. Custom providers must supply the corresponding endpoint contract and processing integration; copying a route declaration alone is insufficient.

Integration pieceResponsibility
Provider runtime contractEndpoint key, signature source and event vocabulary
Backend secret configurationVerification material for the deployed endpoint
Provider adapter or engine verifier, and the owning handlerVerify, interpret and process the event
Scope bindingResolve the event’s customer/account context

A raw-body signature integration test exercises the request pipeline, while a production connection still needs the provider’s real delivery setup. Preserve the raw body until verification, and keep inbound verification separate from the ES256 contract used for Wildo’s outbound customer webhooks.

Use external records where your application needs them Mechanism

Data can remain in its original system, where your application reads it when needed and, where you allow it, changes it. Or it can become a synchronized part of your application, copied in, sent out or kept in step on both sides. Wildo supports each choice through resource declarations.

Read-through suits remote reference data and records people correct in passing. A local copy lets your application add its own fields, attachments and relationships, with freshness determined by the synchronization schedule.

Example: Keep customer accounts and local notes together

An application copies company accounts from Odoo, then lets its users add internal notes. A later synchronization updates the mapped account fields while the application retains the fields it owns.

External records can be read directly or synchronized into a local copy with application-owned notes.
For engineers

A virtual resource uses persistenceAdapter: HTTP_API with httpApiBinding. Its repository translates supported reads to the external system and sends single-record creates, updates and deletes for the verbs the binding opens; it has no local row for extra fields or files. A pipeline declaration belongs to an ordinary local resource and populates that resource through the data-seeding engines, or, declared outbound or two-way, sends its rows to the external system.

Both use HttpApiReadClient for access, dialect framing and remote calls. A binding names the system of record it reads, declared once under systemsOfRecord in wildo.saas.config.ts, and declares the remote entity and key mapping, which are required semantic decisions, not optional labels. Who answers for the records’ personal data defaults to the system of record and is declared only when it differs. A system of record belongs to one owner organization, and its rows are stamped with that organization’s id; on an instance declared partitioned, they are further confined to the organization units each remote company is assigned to.

This pipeline block is selected from Wonder Todos’ external-customers.resources-config.ts; its partition fields, source comments, and the surrounding resource factory and operation declarations are omitted:

  externalDataPipeline: {
    binding: {
      dialect: HttpApiTransportDialect.ODOO_JSONRPC,
      system: 'odoo',
      entityRef: 'res.partner',
      keyFields: [{ localField: 'id', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
      subjectRights: { posture: SystemOfRecordSubjectRightsPosture.HANDLED_AT_SOURCE },
      purposeRefs: ['task-planning-service'],
    },
    remoteKeyLocalField: 'odooPartnerRef',
    mapping: [
      {
        kind: ExternalDataMappingEntryKind.CONCATENATION,
        localField: 'name',
        parts: [
          { kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'name' },
          { kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'ref' },
        ],
        separator: ' · ',
      },
      { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'email', localField: 'email' },
    ],
    sourceFilter: { is_company: true },
    population: ExternalDataDestinationPopulation.CLOSED,
    orphanPolicy: ExternalDataOrphanPolicy.IMPERSONALIZE,
    scheduleCron: '0 * * * *',
  },

The closed population means the source supplies the records: this resource declares no caller create operation. Its local internalNote is editable, while imported fields exclude create/update input in the schema. The mapping owns only its declared fields, one of them built by a declared expression from two remote fields. The subject-rights posture says Odoo, which the application only reads, answers requests about its own client records, so an erasure that reaches these rows completes and its receipt names Odoo. A client that no longer appears in a complete pass has its copied name and email, and its note, scrubbed in place (IMPERSONALIZE), and the row is kept. Register the schema, resource factory and relationships through the shared module, and supply the system of record’s address and credential for each environment.

Treat synchronization as a population contract

remoteKeyLocalField holds a stable identity derived from the external binding. An extraction that reaches the source’s end selects sync reconciliation; a bounded pass selects upgrade reconciliation and persists its continuation. A persisted membership journal keeps the identities each run of a pass observed, so the final comparison covers the whole pass rather than only the last run’s rows.

RequirementSuitable lane
Read the external response on demandVirtual read-through
Change a record where it livesVirtual read-through with opened write verbs
Add local notes or filesLocal pipeline destination
Avoid remote latency on ordinary readsLocal pipeline destination
Send records the application createsOutbound pipeline
Let both sides edit one recordTwo-way pipeline, with each field’s owner declared
Keep no duplicated rowVirtual read-through

Start with REPORT until the population and its filter are understood: automatic orphan deletion acts on what a completed pass did not see, and a filter or permission change at the source can make records vanish from view without being deleted. A completed extraction is not a claim that every row passed transformation; inspect rejections and reconciliation results. Do not declare NO_SUBJECT_DATA for a source containing people’s information: the factory refuses it beside any mapped field whose declared category describes a person.

Work with Odoo from your application Mechanism

A company that runs its business in Odoo can build internal tools on the records Odoo already holds. The application declares the company’s Odoo once, by name, and reaches it with its own integration key, never with one of its users’ accounts.

Read in place: a list or record screen asks Odoo when it opens, so it always shows what Odoo holds. Copy kept current: a scheduled pipeline copies chosen fields into the application, where they sit beside fields only the application owns, and it can read only the records changed since its last pass.

Write back: a resource can create, change and delete Odoo records, and an update is checked against the version the person was looking at, so most edits made over someone else’s newer change are refused instead of silently overwriting it. Actions: an Odoo operation such as posting a draft invoice can be run from the application, under the same roles, company limits and audit as a write.

Per company: when one Odoo database holds several companies, each subsidiary’s people see, change and act on their own company’s records only. Both directions: a pipeline can send records created in the application to Odoo, or keep records edited on both sides in step, with each field owned by one side or shared.

Example: Client accounts and invoices for a services firm

A project tool copies client accounts from Odoo every hour and lets the team add internal notes; Odoo stays the source of each client’s name and email. Finance managers maintain the catalogue of billable services in Odoo from the tool’s own screens, and post draft invoices from it. A manager of one subsidiary does all of this for that subsidiary’s company only.

Within one subsidiary, Odoo client accounts are copied into a project tool with local notes, while catalogue edits and invoice posting go back to Odoo.
For engineers

An Odoo deployment is a system of record, not a provider: nothing selects it through the capability plane, and no person’s connected account is spent on it. Wonder Todos declares its Odoo under systemsOfRecord in wildo.saas.config.ts, comments omitted:

systemsOfRecord: {
  odoo: {
    vendor: 'odoo',
    dialect: HttpApiTransportDialect.ODOO_JSONRPC,
    partition: true,
    personalDataOrigin: SystemOfRecordPersonalDataOrigin.NO_PUBLICLY_ACCESSIBLE_SOURCES,
  },
},

The name odoo is yours to choose. It identifies this instance, so a second deployment of the same vendor is a second name. The odoo vendor facet ships in the systems-of-record library, @wildo-ai/systems-of-record, which wildo config sync adds to the backend as soon as a system of record is declared. The facet offers one credential kind, so the declaration does not name it. partition: true turns on per-company confinement, and personalDataOrigin tells the privacy notice that the personal data in this Odoo was collected by the company itself, none of it from public sources. Three Wonder Todos resources bind the instance by name: a copied client list, a read-through service catalogue that writes back, and read-through invoices with a posting action.

Supply the address and key per environment

The facet asks for three address values, baseUrl, database and login, and one secret, apiKey. Addresses go under systemsOfRecord.odoo in the environment’s wildo.infra.<env>.config.ts; the key goes in that environment’s secrets.json. You never write a variable name: they are derived from the instance name as WILDO_SOR_ODOO__BASE_URL, WILDO_SOR_ODOO__DATABASE, WILDO_SOR_ODOO__LOGIN and WILDO_SOR_ODOO__API_KEY.

An environment with no access to the deployment, such as a developer machine, declares odoo: { enabled: false }, as Wonder Todos’ local environment does. The application still boots, and reads of bound resources are refused rather than answered as empty. Leaving the instance out of an environment’s configuration is refused, because it cannot be told apart from a forgotten address. At boot the engine joins the declaration, the vendor facet and the environment, and refuses an incoherent instance; it does not contact Odoo, so an Odoo outage does not stop the application starting.

How the client authenticates

The shared client calls Odoo’s authenticate with the configured database, login and key to obtain a user id, then puts database, user id and key in the arguments of every model call. The user id is cached against the credential it was obtained with, so a rotated key authenticates again without an invalidation step. The configured Odoo user decides what the application can reach: it must have access to the models and methods the resources use.

Copy client accounts

The following is the binding and mapping portion of Wonder Todos’ externalCustomers pipeline, with comments omitted. Its resource schema, operations and registration surround this excerpt. SystemOfRecordSubjectRightsPosture and HttpApiTransportDialect are imported from @wildo-ai/saas-models; the other enums come from @wildo-ai/saas-models/external-data.

externalDataPipeline: {
  binding: {
    dialect: HttpApiTransportDialect.ODOO_JSONRPC,
    system: 'odoo',
    entityRef: 'res.partner',
    keyFields: [{ localField: 'id', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
    subjectRights: { posture: SystemOfRecordSubjectRightsPosture.OWNED_COPY },
    purposeRefs: ['task-planning-service'],
  },
  remoteKeyLocalField: 'odooPartnerRef',
  partitionUnitLocalField: 'organizationUnitId',
  partitionKeyLocalField: 'odooCompanyRef',
  mapping: [
    {
      kind: ExternalDataMappingEntryKind.CONCATENATION,
      localField: 'name',
      parts: [
        { kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'name' },
        { kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'ref' },
      ],
      separator: ' · ',
    },
    { kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'email', localField: 'email' },
  ],
  sourceFilter: { is_company: true },
  population: ExternalDataDestinationPopulation.CLOSED,
  orphanPolicy: ExternalDataOrphanPolicy.IMPERSONALIZE,
  scheduleCron: '0 * * * *',
},

The binding declares no tenancy. Rows read out of a system of record are stamped with the organization that owns the application, so the ordinary per-row tenant check decides who sees them and nothing about your tenants is sent to Odoo. The integer remote key identifies the source record; odooPartnerRef retains it on the local row.

The subject-rights posture follows from what the copied fields can hold. A client’s name and email can be a person’s: a sole trader is recorded as a company in Odoo, and a named contact’s address on a company record is theirs. The is_company filter leaves out the individual contacts filed under each account; it does not make the remaining records a legal person’s. So the destination declares name and email as personal data. The copy also keeps internalNote, which a team member writes and Odoo does not hold, so the binding declares OWNED_COPY: the application keeps this copy beyond its source and erases it itself, while Odoo, the company’s own system, answers requests about the records it came from. Without the note the copy would follow Odoo (HANDLED_AT_SOURCE, the default), leave an erasure to it, and remove what Odoo removes. NO_SUBJECT_DATA would be refused beside fields declared as personal data, and FORWARDED_TO_SOURCE is refused on a pipeline that only reads, since nothing sends a delete through it. purposeRefs names why the copy exists, so the privacy notice can list Odoo as a source of these fields for that purpose.

An erasure through the application does reach the client list. Each client row is its own data subject: it is no one’s login, so erasing it ends no session, and the person is found by what identifies them, usually the email. The todos done for a client point at its row, so an erasure keeps the row and scrubs name, email and internalNote in place and records an erasure suppression so the next pass does not copy that Odoo record back. A client that no longer appears in a complete pass, because it was deleted in Odoo, stopped matching the filter or can no longer be read, has the same three fields scrubbed in place by that pass (IMPERSONALIZE). That scrub writes no suppression, because nobody asked for the record to be forgotten: if Odoo lists it again, it loads as a new row, and the todos keep pointing at the scrubbed one. A row under legal hold is left unchanged. Reassess every one of these choices for a different population.

The two partition fields are written by the engine, never by the mapping. organizationUnitId receives the unit the client’s Odoo company is assigned to, which is what confines a subsidiary’s members to their own clients. odooCompanyRef keeps Odoo’s company_id itself, so a client still says which company it belongs to after that company is reassigned or unassigned. A company whose assignment is withdrawn is not mistaken for clients that disappeared, because each pass still reads its clients and counts them as present without loading them; until the company is assigned again, those clients are read by nobody here, organization-wide members included. Both stay empty on a client that belongs to no company, which every caller may see. The name shown is a declared expression: the client’s name followed by Odoo’s reference (Acme Industries · C-0042), with the separator dropped when there is no reference.

This pipeline runs a full pass every hour. An Odoo pipeline can instead read only the rows changed since its last pass, by write_date (ExternalDataIncrementalExtractionMode.ODOO_WRITE_DATE), with a periodic full pass to notice deletions, and an inbound notification can ask it to re-read particular records by id.

Write through to Odoo

Wonder Todos’ billable-service catalogue has no local table: it is read from Odoo’s product.template on every request, and its writes go straight to Odoo. The binding, comments omitted:

persistenceAdapter: PersistenceAdapter.HTTP_API,

httpApiBinding: {
  dialect: HttpApiTransportDialect.ODOO_JSONRPC,
  system: 'odoo',
  entityRef: 'product.template',
  keyFields: [{ localField: 'odooProductId', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
  fieldMappings: [
    { localField: 'listPrice', remoteField: 'list_price' },
    { localField: 'unitOfMeasure', remoteField: 'uom_name' },
    { localField: 'lastUpdatedAt', remoteField: 'write_date' },
  ],
  subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
  writableVerbs: [HttpApiWriteVerb.CREATE, HttpApiWriteVerb.UPDATE, HttpApiWriteVerb.DELETE],
  writeConcurrency: { mode: HttpApiWriteConcurrencyMode.VERSION_CHECKED, versionField: 'lastUpdatedAt' },
},

Each opened verb is one execute_kw call (create, write or unlink) under the instance’s own identity, and each has its operation, gated on ORG_MANAGER. Every write requires an Idempotency-Key: Odoo offers no idempotency of its own, so a write is sent once, an interrupted one answers that its outcome is unknown, and a retry under the same key gives that answer again instead of creating a second product. Under VERSION_CHECKED an update carries back the write_date the caller read, and is refused when Odoo’s is newer. A product is not a person, which is why this binding may declare NO_SUBJECT_DATA; pointing it at people would change that.

Run an Odoo method as a declared action

Posting an invoice is not a row update: Odoo validates it, numbers it and writes its journal entries. Wonder Todos’ customerInvoices binding on account.move declares it as an action, comments omitted:

actions: {
  [CUSTOMER_INVOICES_POST_ACTION]: {
    target: SystemOfRecordActionTarget.RECORD,
    call: {
      shape: SystemOfRecordActionCallShape.ODOO_EXECUTE_KW,
      method: 'action_post',
      inputPlacement: SystemOfRecordOdooActionInputPlacement.NONE,
    },
    input: z.object({}),
    writes: true,
    retrySafe: false,
    roles: [CORE_ORG_ROLES.ORG_MANAGER],
    operation: CustomerInvoices_Operations.POST_INVOICE,
  },
},

Its only HTTP door is the resource’s post_invoice operation, PUT …/customer-invoices/{id}/post_invoice, which requires an Idempotency-Key, is offered only on a draft, and hands the request to the system-of-record action executor. The executor checks the role in the owning organization, reads the invoice first so another company’s invoice answers as absent, sends one execute_kw('account.move', 'action_post', [[id]]), and records the outcome. Odoo offers no idempotency for a method call, so retrySafe is false and a lost answer is never re-sent.

Send local changes, or keep both sides in step

Wonder Todos’ Odoo pipelines only read. The same declaration can run the other way: an outbound pipeline creates in Odoo a record first made in the application, keeps the id Odoo answers, and sends later edits as updates. A two-way pipeline loads the fields Odoo owns, sends the fields the application owns, and for a field both sides edit applies its conflict policy or reports the conflict. Because Odoo serves write_date only to the second, two-way agreement also compares a fingerprint of the shared values, so a second edit within the same second is not missed.

Keep compatibility tied to the configured server

This dialect targets the classic JSON-RPC contract implemented by its compiler and client. Protocol-simulator tests exercise authentication, extraction, writes, actions, changing source populations and both sending directions; they do not certify every hosted Odoo version or deployment. Verify the instance’s exposed API and the integration user’s permissions before depending on it for a business workflow.

Receive signed messages from other systems Mechanism

An application declares its own webhook endpoint and the code that handles it. Each message is verified, stored once, run in the right organization, retried on failure and replayable by an administrator.

Verified before anything is stored. The endpoint accepts Svix-style signatures (the convention Resend and Standard Webhooks follow) or an HMAC signature in a declared header. A refused signature is answered 401 and audited, and nothing is stored. The endpoint is not mounted at all until its secret is configured.

What you decide

Write the handler with defineInboundHandler and add it to a backend module’s inboundHandlers: the endpoint key, the verification scheme, the payload schema, the scope mode and the acting roles. Supply the endpoint’s secret through the application’s secrets. The handler itself only decides what a message means.

A handler that creates a todo

Selected from todo-intake.inbound-handler.ts; the ordering key, the reply mode and the handle body are omitted.

export const todoIntakeInboundHandler = defineInboundHandler({
  ref: inboundHandlerRef('todos.intake'),
  source: {
    kind: InboundSourceKind.APPLICATION_ENDPOINT,
    endpointKey: 'todo-intake',
    verification: { scheme: InboundVerificationScheme.SVIX_V1 },
    eventTypes: [TodoIntakeEventType.TODO_REQUESTED],
  },
  payloadSchema: TodoIntakePayloadSchema,
  // …
  scopeMode: InboundHandlerScopeMode.ORGANIZATION_REQUIRED,
  // The tenant is whoever owns the named list — read from this application's data, never from the sender.
  resolveScope: async (_delivery, payload, lookups) => {
    const organizationId = await lookups.findOrganizationIdByField(TasksManager_ResourceType.TODO_LISTS, '_id', payload.todoListId);
    return organizationId ? { kind: InboundResolvedScopeKind.ORGANIZATION, organizationId } : { kind: InboundResolvedScopeKind.UNROUTABLE };
  },
  actingRoles: { organization: [CORE_ORG_ROLES.ORG_MEMBER] },
  // …
  retry: { maxAttempts: 3, maxWindowSeconds: 60 * 60, backoff: { kind: InboundRetryBackoffKind.FIXED, baseSeconds: 20 } },
  // …

The endpoint key todo-intake is mounted at POST /webhooks/app/todo-intake and verified with the Svix scheme. The organization is the one that owns the todo list the message names, looked up in the application’s own data; a list no organization owns makes the delivery unroutable. Whatever the sender, the handler acts as an organization member, and a failing delivery is retried three times, twenty seconds apart, before it is marked dead.

Limits

One endpoint has one secret for the whole application, so a valid signature identifies the sending system, not a customer. A sender that must be confined to one organization needs a per-organization source, which is what inbound channels provide.

For engineers

An application declares an inbound handler: the endpoint key, how its sender signs requests, the payload it accepts, which organization a message belongs to, and the roles the handler may act with. The engine mounts the endpoint and does everything between the wire and the handler’s code.

The problem it solves

A customer’s system, a partner or an automation tool needs to tell the application that something happened. Every team that writes such an endpoint by hand has to get the same things right: checking the signature against the raw bytes, not processing the same message twice, not losing a message when the handler fails, and not letting a message write into the wrong customer’s data. Each of those is easy to miss and invisible until it causes harm.

What it does

Stored once, then processed. A verified message is stored as a delivery with a derived deduplication key, so the same message sent again changes nothing. A worker runs the handler, and a failed attempt is retried on the handler’s policy. When every attempt has failed the delivery is marked dead, where an administrator can replay it from the settings.

The organization comes from the application, never from the sender. A handler either runs every message at application scope, requires each message to resolve to one organization, or decides the scope per message. A message that must belong to an organization and cannot be resolved to one is recorded as unroutable and never run. It is not run with application-wide authority as a fallback.

The handler’s authority is what it declares. It acts with the roles it lists for the scope it runs in, and the engine records the acceptance, processing and any rejection in the audit trail.

Signed messages are verified, stored, and processed once, with failed processing retried and an administrator able to replay the stored delivery.

Take in messages and answer on the thread

Handle incoming messages as conversations Mechanism

Incoming messages are filed into conversation threads linked to the case they concern, each with a stated level of confidence about who sent it, and a handler can reply on the same thread.

One thread model for every channel. A thread holds the exchange with one counterpart on one channel and can be linked to the case it concerns. Its messages record both directions. What a message was on its channel (bodies, headers, attachments) stays in that channel’s own record.

What you decide

Write the handler with defineInboundChannelHandler: the channel, which messages it takes, the minimum sender assurance, whether it replies, an optional handler for messages below the minimum, and its acting roles.

Screen senders the application cannot confirm

The example application gives its +screened address a handler for messages from senders whose domain does not pass DMARC. Selected from todo-email.inbound-channel-handler.ts; the shared intake code, the default handler and the screening handler’s body are omitted.

export const todoEmailScreenedHandler = defineInboundChannelHandler({
  ref: EMAIL_SCREENED_REF,
  source: { kind: InboundSourceKind.CHANNEL, channel: InboundChannelKind.EMAIL, routeTag: TODO_EMAIL_SCREENED_ROUTE_TAG },
  replyMode: InboundReplyMode.THREAD,
  minimumSenderAssurance: InboundSenderAssurance.DOMAIN_ALIGNED,
  belowMinimumHandlerRef: EMAIL_SCREENING_REF,
  actingRoles: { organization: [CORE_ORG_ROLES.ORG_MEMBER] },
  handle: (ctx, message) => intake(ctx, message),
});
// …
export const todoEmailScreeningHandler = defineInboundChannelHandler({
  ref: EMAIL_SCREENING_REF,
  source: { kind: InboundSourceKind.CHANNEL, channel: InboundChannelKind.EMAIL },
  replyMode: InboundReplyMode.NONE,
  minimumSenderAssurance: InboundSenderAssurance.UNVERIFIED,
  actingRoles: { organization: [CORE_ORG_ROLES.ORG_MEMBER] },
  // …

A domain-aligned message to the screened address reaches the first handler, which may answer on the thread. Anything below that goes to the screening handler, which is declared not to reply, so ctx.reply is not available to it. It has neither a route tag nor the default, so it is reachable only as the other handler’s below-minimum handler. Both act with the same organization-member role: the sender’s assurance decides which handler runs, never what it may do.

Limits

Email is the only channel with a provider behind it. The mechanism has run end to end against a stand-in for the email provider, but not yet against a real provider account, and no running application has yet shown a record’s linked conversations in its interface.

For engineers

A channel is how a message reached the application. A channel handler runs on each message after the engine has routed it to an organization, worked out how much is known about who sent it, and filed it in a conversation thread. The handler can then answer on that thread.

The problem it solves

Service work arrives as messages: a client writes, the firm answers, the client replies. Treating each message as an isolated event loses the conversation, and every handler ends up re-inventing how to tie a reply back to its case.

Messages also claim a sender they cannot prove. An email address or a domain can be forged, and acting on it as though it identified a person would let anyone who can send a message steer the application.

What it does

A stated level of confidence about the sender. Each message carries a sender-assurance level from its channel’s own ladder. For email: unverified, domain aligned (the sending domain passed DMARC), a known contact of the organization, or bound to a reply address the application issued for this thread. For a signed webhook: unverified or signed by the source. These levels identify; they never authenticate a person.

Assurance gates, it never grants. A handler declares the lowest level it will act on. A message below it never reaches that handler. It goes to a declared handler for such messages, or is stored and audited and dispatched to nobody. Whatever the level, the handler acts only with the roles it declares.

Replies stay on the thread. A handler declared to reply can answer through the channel’s outbound path. The reply is recorded on the thread and sent at most once, even when the handler or the sending worker is retried. A handler declared not to reply cannot. Closing a thread revokes its reply addresses.

Incoming messages carry checked sender assurance, join a case’s thread, and receive replies on that same thread.

Receive email into the right customer workspace Feature

Email sent to an organization's address, its own verified domain or a relationship mailbox arrives in that organization, with attachments scanned before anything acts on the message.

The address decides, and only the address. A message is routed in a fixed order: a reply address the application issued (which must belong to the organization the address reaches), then a relationship mailbox, then a domain the organization has verified, then the organization’s address on the application’s shared domain. Anything else is unroutable and audited.

What you decide

Select an inbound email provider, declare an email channel handler, and decide what a message causes in your application. Organizations decide whether to claim their own domains.

Select the provider that receives

The example application enables EngineCapability.EMAIL_INBOUND and lets Resend both send and receive. Selected from wildo.saas.config.ts; the backend scope’s other providers and selections are omitted.

resend: {
  engineCapabilities: [EngineCapability.EMAIL_TRANSACTIONAL, EngineCapability.EMAIL_INBOUND],
  providerCapabilities: ['EMAIL_TRANSACTIONAL', 'EMAIL_INBOUND'],
  protocols: ['EMAIL_PROVIDER', 'EMAIL_INBOUND_PROVIDER', 'WEBHOOK_ORIGINATOR'],
},
// …
[EngineCapability.EMAIL_INBOUND]: {
  primary: 'resend',
  whenUnavailable: [],
},

Receiving takes two protocols beside sending: EMAIL_INBOUND_PROVIDER, through which the engine fetches a received message and its attachments, and WEBHOOK_ORIGINATOR, which mounts the endpoint the provider notifies when a message arrives. The selection names Resend as the inbound provider with no fallback. Nothing here names an organization: the address a message was sent to decides that.

Limits

This has run end to end against a stand-in for the email provider. It has not yet run against a real provider account. An agent started from an email and pausing for review is not yet part of that run.

For engineers

An application that selects an inbound email provider can receive mail on behalf of its organizations. Each message is routed to one organization by the address it was sent to, stored with its attachments, and handed to the application’s email handler, which can create a record and answer on the thread.

The problem it solves

For a service firm, email is where clients send work. Forwarding it by hand into the right workspace is slow, and routing it automatically is dangerous if done carelessly: a sender’s address is easy to forge, so an application that decided the destination from the sender would let anyone write into any customer’s data.

What it does

Organizations can use their own domain. An organization claims a domain and proves ownership through a DNS record. The domain receives nothing until it is verified, a competing claim cannot take it once ownership is proven, and it is suspended if its records disappear. Replies can then leave from that domain.

A firm can keep a mailbox for each client. A relationship mailbox on the firm’s domain delivers into the client’s own organization, where the case lives, and only while the relationship between the firm and that client is active.

Nothing acts on an unscanned attachment. A message waits until every attachment has a scan verdict. An infected attachment, or one whose scan failed, is withheld and listed on the message. Size and count limits are enforced, and the raw message can be kept or discarded.

The rest is the channel’s. The message is filed on a conversation thread with a sender-assurance level. A handler that requires more assurance than a message carries never sees it.

An address is not reused. When an organization is deleted, its address is retired, so a new organization with the same name never receives the old one’s mail.

Inbound email reaches a client workspace by mailbox address, with attachments scanned first and the sender excluded from routing.

Reach people on another channel

Send transactional text messages Mechanism

Wildo’s SMS service sends transactional text messages through a configured provider. Phone verification and SMS sign-in challenges use that service, keeping provider access and transport out of each authentication flow.

This is the transactional send path. Resource notifications cannot be sent by SMS: the notification channels are email, in-app and front-end confirmation, and an SMS channel needs a text for each notification, which none has yet.

Example: Deliver a phone verification code

A person requests phone verification. The authentication flow supplies the message to the SMS service, which resolves the selected provider and submits its request. The person then enters the received code.

A transactional verification message passes through the configured SMS provider.
For engineers

Enable EngineCapability.SMS, configure an enabled provider using SMS_PROVIDER, select its primary provider and supply the provider’s credential and sender configuration. Wonder Todos currently declares SMS disabled; that configuration is not a working send example.

For example, select the shipped Twilio backend provider in wildo.saas.config.ts. Import EngineCapability from @wildo-ai/saas-models. These are entries to merge into the existing configuration, preserving its other capabilities, providers and selections:

engineCapabilities: {
  [EngineCapability.SMS]: { enabled: true },
},
providers: {
  scopes: {
    backend: {
      providers: {
        twilio: {
          engineCapabilities: [EngineCapability.SMS],
          providerCapabilities: ['SMS'],
          protocols: ['SMS_PROVIDER'],
        },
      },
      selection: { [EngineCapability.SMS]: { primary: 'twilio', whenUnavailable: [] } },
    },
  },
},

Run wildo config sync to synchronize the provider artifacts. This is the backend contribution; a provider configured only on a frontend cannot send the authentication message. Keep credentials in the backend deployment environment, not this shared declaration.

The provider’s required TWILIO_CREDENTIALS secret accepts a JSON bundle shaped as {"accountId":"<account SID>","secret":"<auth token>"}. Both values come from the provider account. The engine resolves that secret, uses the account identifier in the send endpoint and constructs the authorization header. The separate fromNumber below belongs to backend application configuration and must identify the intended sender.

Set the sender, then handle the send result

The backend service is injected as SAAS_SERVICE_TYPES.SMSService. Its default sender comes from the application configuration path below; configuredSenderNumber is the number approved for your provider account. Import ExternalProvider_ExchangeProtocol_Kind from @wildo-ai/external-connectors-models. This is a configuration fragment, not a credential declaration.

providerConfigurations: {
  twilio: {
    [ExternalProvider_ExchangeProtocol_Kind.SMS_PROVIDER]: {
      fromNumber: configuredSenderNumber,
    },
  },
},

In the calling service, smsService is the injected SMS service, recipientPhone is an E.164 number and localizedMessage is the application’s translated message. This illustrative body shows the result boundary:

const result = await smsService.send(recipientPhone, localizedMessage);
if (!result.success) {
  throw new Error('The SMS provider did not accept the message');
}

const submission = {
  providerId: result.providerId,
  messageId: result.messageId,
};

The optional third argument overrides the configured sender for that call. success describes provider submission; neither that value nor the optional message identifier proves handset delivery. Preserve failure information in the application’s normal diagnostics without exposing message contents or credentials. Do not mark a verification challenge delivered simply because the promise resolved.

The existing SMS sign-in and phone-verification controllers follow this pattern: obtain a localized message, call send, inspect success, and reject the request on failure. Keep challenge generation, throttling and validation in those authentication flows rather than constructing a second authentication flow around this short send example.

Let the provider own its wire format

The runtime contract supplies the send endpoint, request-body builder and response parser. The engine resolves credentials, composes its authorization header, executes the bounded request and validates the parsed response against StandardSMS_SendResponseSchema. A provider’s accepted response is not proof of handset delivery.

Twilio is the shipped provider for this path. Its account-and-secret credential shape is parsed by the engine; secret values remain deployment material. The provider contract determines encoding and response interpretation rather than making every caller speak Twilio.

Keep the notification channels distinct

Authentication consumers invoke this service for codes and phone verification with their own throttling and continuation checks. The general notification dispatcher has no SMS channel, so an operation’s notifications cannot reach it. Use the existing transactional API for its supported workflows, and do not advertise SMS notifications a product cannot send.

Mobile push delivery is planned Planned Mechanism

Planned — not available yet.

There is no mobile push delivery today. Nothing registers a device, no provider sends to one, and the notification channel vocabulary has no push member: an earlier push channel accepted declarations and sent nothing, so it was removed rather than left to look available.

For notifications available today, use the implemented in-app or email channels, or the transactional SMS service for its supported workflows.

Example: Choose an available notification path

A product needs to notify someone of a change now. It uses an implemented notification channel: a live in-app message for someone in the application, or an email for someone who is not.

Mobile push is a planned notification channel without an implemented device-delivery path.
For engineers

The notification channels are declared in notifications.shared.schemas.ts:

export enum CoreUserNotificationChannel {
  EMAIL = 'email',
  WEBSOCKET = 'websocket',
  FRONT_END_SUCCESS = 'front_end_success',
}

A working push feature needs a device-subscription lifecycle, a sender/provider contract, payload templates and delivery handling. Those are implementation work, not configuration an application author can switch on.

Use in-app messages for an active application session, and the implemented notification channels appropriate to the product’s reach requirements. Transactional SMS is a separate service and does not imply general push support.

Let systems work together without losing their responsibilities.

An integration brings more than an endpoint into an application. It brings another account, another data owner and another way for work to succeed or fail.

Wildo gives those connections a shared structure. Your application keeps control of the workflow, while provider, exchange and system-of-record contracts keep the services around it understandable.

Building a B2B product or an internal tool?

Wildo is not self-service yet. Tell us what you have in mind and we will say plainly whether it fits, and what happens next.