Home / Docs / Integrations

Integrations

An integration is a pluggable adapter: an independent worker that knows how to talk to one external system — Stripe, Grafana, AWS, GitHub — and exposes it to Yggdrasil through an explicit contract. Connect everything you operate and compose them like Lego pieces: swap a provider without rewriting the workflow that uses it.

What an adapter does (and what it doesn't)

An adapter is the declarative infrastructure layer: it reconciles the state of external resources (third-party APIs, systems, clouds) with the desired state you express — guaranteeing idempotency, adoption of pre-existing resources, and drift detection. It does not carry business logic, has no database of its own, and makes no assumption about which cloud, secrets vault, or broker you use.

The boundary is sharp: Yggdrasil handles what is the company's internal responsibility (registering the Stripe webhook in the dashboard, provisioning the S3 bucket for assets, creating the team's repository). Charging an end customer or storing their payment is business — it lives in the backend, not here.

The family → type → instance → provider model

Four levels organize the integration catalog. Understanding them is half the battle:

family
The shared contract — the set of operations that one or more providers implement. An adapter declares a family (e.g. grafana) and registers it as the authority over the capability vocabulary.
integration_type
A concrete implementation of the family, with its Describe(), schemas, and version. A single family can have several types: grafana-runtime (an HTTP client against an existing Grafana) and grafana-kubernetes (the installation generator) coexist under the grafana family.
integration_instance
The tenant. One type serves N instances, each with isolated credentials: stripe-acme and stripe-corp are two instances of the stripe type, each with its own API key and webhook secret. The instance is the multi-tenant unit.
provider
The running worker — the adapter pod (the Go binary) that actually translates capabilities into calls to the external system. The Provider field in Describe() identifies the family it serves.

The Describe() contract

At boot, the core performs a handshake with each adapter: it requests Describe() and validates the catalog before dispatching any work. If the handshake fails, the adapter is not eligible for execution. Describe() publishes everything the core needs to know: the version, the transport, the resource types, the credential and instance schemas (with UI metadata that surfaces consume), and the action catalog.

Below is a trimmed-down shape of the stripe adapter's Describe() (AdapterVersion 2.4.0):

describe.stripe.yaml
provider: stripe
adapter:
  version: "2.4.0"
  transport: http_json          # RPC over HTTP
  timeout_seconds: 30
  endpoints:
    describe: /rpc/describe
    execute:  /rpc/execute
capabilities: [describe, execute]

credential_schema:             # tenant secrets (never inline in a manifest)
  properties:
    stripe_api_key:
      type: string
      secret: true
      label: "Stripe API key"

instance_schema:               # non-secret per-instance config
  properties:
    stripe_webhook_secret: { type: string, secret: true }
    stripe_account_id:     { type: string }   # optional: Stripe Connect

resource_types:                # resources with a stable external identity
  - name: customer
    canonical_prefix: thirdparty.stripe.customer
    default_actions: [ensure_customer, observe_customers, destroy_customer]
  - name: webhook_endpoint
    canonical_prefix: thirdparty.stripe.webhook_endpoint
    default_actions: [ensure_webhook_endpoint, observe_webhook_endpoints, destroy_webhook_endpoint]

Each resource type has a canonical trio: ensure_* (idempotent mutation that adopts what already exists), observe_* (read, no effect), and destroy_* (terminal removal — a 404 from the provider counts as success). There's also discover_* (provider-side enumeration) and out-of-trio actions on the allowlist, such as create_refund (money movement) and verify_webhook_signature (a pure helper). A linter (contractcheck) runs in CI and breaks the build if Describe() diverges from what Execute() actually accepts.

CRUD names (create_*, list_*, get_*, delete_*, update_*) for resource operations are forbidden — they break the convention. Older renames survive only as legacy aliases, resolved at the edge of Execute() and removed in the next major.

Transports

The core talks to the adapter over one of two transports — declared in Describe().adapter.transport and chosen by the worker via the YGGDRASIL_TRANSPORT variable:

TransportWhenHow
http_json Default. YGGDRASIL_TRANSPORT absent, http, or http_json. The worker calls ListenHTTP on ADAPTER_PORT (default 8081) and serves /rpc/describe + /rpc/execute. A separate health server runs on HEALTHCHECK_PORT (default 8080) with /healthz and /readyz.
rabbitmq / amqp Opt-in. YGGDRASIL_TRANSPORT=amqp (or rabbitmq). The worker calls ListenAMQP against BROKER_URL (fatal if empty) and consumes from the queues yggdrasil.adapter.<type>.describe and yggdrasil.adapter.<type>.execute.

The adapter's work is the same in both cases — Describe() and Execute() don't know which transport carried them. By the same Lego logic, the broker is an operator's choice: the adapter never opens a direct connection to a hardcoded RabbitMQ.

Install an integration

The fast path is yggdrasil install <repo_ref>. The CLI fetches the repository's yggdrasil-quickstart.yaml, walks you through the declared inputs (TUI by default, or --non-interactive for CI), and POSTs the installation to the core, which compiles the workflow that brings up the pod and registers the instance.

terminal
$ yggdrasil install dakasa-yggdrasil/integration-stripe
# pin a version
$ yggdrasil install dakasa-yggdrasil/integration-stripe@v2.4.0
# OCI reference (the quickstart image in the registry)
$ yggdrasil install oci://ghcr.io/dakasa-yggdrasil/integration-stripe:v2.4.0
# CI: no TUI, inputs via flags
$ yggdrasil install dakasa-yggdrasil/integration-grafana \
    --provider grafana-runtime --non-interactive \
    --input instance_name=grafana-prod --dry-run
 installation compiled (--dry-run: not dispatched)

The repo_ref accepts owner/repo, owner/repo@v1.2.3 (pinned ref), owner/repo:path.yaml (custom manifest), and oci:// refs — latest or a specific tag. Use --server/--token (or your login's active context) to point at the core; --dry-run asks the server to compile the workflow without dispatching it.

Build your own

To write a new adapter, start from the official template. The CLI clones dakasa-yggdrasil/integration-template, rewrites the Go module and the integration name, and leaves a skeleton that already compiles and passes its tests:

terminal
$ yggdrasil new integration datadog --owner acme
 scaffold ready
  directory: ./integration-datadog
  module:    github.com/acme/integration-datadog
# edit the spec and add your operations
# adopters then install with:
$ yggdrasil install acme/integration-datadog

The skeleton ships with Describe()/Execute(), the contract linter, the health endpoints, and the install yggdrasil-quickstart.yaml already wired up. Adapters use the Go SDK (yggdrasil-sdk-go) so they don't reimplement the RPC plumbing: you build an adapter.New(...), register the describe/execute handlers, choose the transport (ListenHTTP or ListenAMQP), and call Run.

When designing capabilities, follow the canonical prefixes. The guiding question: does the thing I'm manipulating have a stable external identity?

ensure_<resource>
Idempotent mutation. The caller says "I want this state"; the adapter does GET-then-PUT internally, adopting the resource if it already exists. Same input → same output.
observe_<type>
Read, no side effect. Lists resources, optionally filtered. Fetching a single item is filter={id:"..."} — there is no separate get_*.
destroy_<resource>
Terminal, idempotent removal. A provider 404 (already gone) counts as success. Every time you add an ensure_*, ship its destroy_* pair in the same PR.

Every mutation (ensure_* / destroy_*) emits an auditable event (<provider>.<resource>.ensured / .destroyed) — the SDK handles this for you when you register the reconciler. That's what separates "I called the API through a wrapper" from "Yggdrasil reconciled the resource and the world found out."

Read the canonical contract in the integration-template (INTEGRATION_CONTRACT.md is the law) and the yggdrasil-sdk-go before touching any adapter. Then head to Surfaces to see how an integration gets its own internal UI.