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:
grafana) and registers it as the authority over the capability vocabulary.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.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 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):
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.
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:
| Transport | When | How |
|---|---|---|
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.
$ 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:
$ 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?
filter={id:"..."} — there is no separate get_*.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."
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.