Documentation menu

Connectors

The connector marketplace, the four core capabilities the platform special-cases, one standardized contract for everything else, and how to bring your own.

Atrium talks to a lot of external services: a notification sender, your identity provider, an LLM gateway, error tracking, hosting, DNS. Rather than hard-coding each one, Atrium models them as connectors you browse and configure at runtime. Credentials are stored encrypted (AES-256-GCM); nothing lives in deploy-time environment variables.

Two surfaces, two audiences:

SurfacePathWho
Marketplace (browse, Add)/marketplaceAny signed-in user can browse. Add is admin-only.
Connector config (credentials, test, enable)/admin/org/connectors/<providerId>Admin

The config page is addressed by product, not by capability. A connection's product is fixed when you create it: there is no provider switcher on an existing instance, because turning an Azure connection into a Dokploy one is not an operation that exists. "Add another connection" adds a second instance of the same product, for example a second DNS zone.

Core capabilities and one generic contract

Most integrations are the same shape: a third-party service you provision group-based access into. So Atrium does not special-case observability vs tracing vs hosting. Only four capabilities are privileged, the ones the platform itself behaves differently for, and everything else is a single generic access connector.

Core capabilityWhy it is specialProviders today
NotificationsHow the platform reaches people, transactional mail todayPlunk, SMTP
IdentityThe IdP: your login and grant storeZitadel
DirectoryWhere group membership is masteredMicrosoft Entra ID
DNSThe platform claims app subdomains in itAWS Route 53, Cloudflare

Everything else is generic, differentiated only by a label and the access levels it offers: LLM gateway, observability, tracing and hosting all run through the same contract and the same reconciler path.

The connector contract

Every connector declares the same things. This is the contract a built-in satisfies and the one a bring-your-own connector must pass the validator with.

  1. Identity: an id, a label, a capability, an icon.
  2. Credential: the fields to collect, each marked secret if it must be encrypted at rest, plus an optional declarative health check so Test connection can prove the credential works.
  3. Access mapping: generic connectors declare the access levels they can grant (a Sentry team, a Langfuse project, a LiteLLM team). An admin maps each Atrium group to one access level. Core capabilities omit this: they do not grant service-side access levels.

The mapping is deliberately uniform, Atrium group -> access-level string, because that shape standardizes across every service.

Field kinds are text, password, number, boolean and group (a dropdown of the org's Atrium groups, storing the group id). A field marked policy is an access-policy setting configured after the instance connects, so the card can render credentials and policy as two sections instead of one flat form.

Add is not the same as connect

Marketplace Add writes a disabled, credential-less row. That row is a pending instance: disabled, with no config value set. The config page deliberately keeps pending instances visible so an operator can finish them; hiding them is what used to leave a blank card.

A disabled instance that does carry config is a different thing: an intentionally switched-off connection, not a pending one, and it is not flagged as pending.

So the lifecycle is: Add (pending), paste credentials, Test connection, Enable. Only the last step makes the connector live.

Primary and fan-out

There is no cardinality concept. Every capability may have several providers connected at once, and connecting one never disables another. Bitbucket and GitHub, Entra and Google, two DNS zones: all normal. An earlier single | multi flag existed only to auto-disable a capability's other providers on save, and it cost an incident where connecting a second DNS provider silently disabled the first and orphaned every record it managed.

What varies is how a capability is consumed:

KindHow Atrium uses itWhich instance acts
Generic access connectorOne-way push. Atrium provisions access outward and holds no opinion about the far sideAll of them. Every enabled instance receives the push
Core capabilityTwo-way. Atrium asks a question and needs one answerThe one marked Primary. The rest stay live and addressable by id

Succession rules

Primacy is a stored flag an operator sets on purpose, never inferred from recency. The rules:

SituationResult
Row is disabledNever primary
Operator ticked "make this the default"That row becomes primary
First enabled instance of a capabilityAdopts primacy automatically, so connecting a single provider needs no second step
Second instance connectsNever steals primacy from the first
Primary is disabled or deletedOldest enabled instance succeeds it, by creation time ascending
Nothing enabled is leftNo primary. The capability is unconfigured and callers no-op audibly

Succession is oldest-first on purpose: the longest-standing connector is the least surprising fallback, and it is stable, whereas an updatedAt sort would reshuffle the order every time a row was re-saved.

Notifications is the capability that forced this to be explicit. Under an older "most recently updated wins" tiebreak, an admin who merely edited the SMTP connector's label re-routed every outbound mail off Plunk, silently, with no UI anywhere saying so.

The invariant the platform maintains: at most one primary per (tenant, capability), and at least one whenever any enabled instance exists. A capability is never left primary-less, because "no primary" degrades back to the same implicit guessing this replaced.

Resolution, caching and staleness

Credentials resolve from the encrypted DB registry. There is deliberately no process.env fallback: a secret in a container env is a secret in plaintext on the host, and "which env var wins" is invisible to an operator reading the config page. The single exception is a one-time seed migration that copies legacy PLUNK_* values into an encrypted row on first deploy and then never again. After that the env vars are inert and can be deleted.

A saved-but-incomplete row (a required secret never set) reads as not configured rather than half-wired, so the caller no-ops audibly instead of calling a provider with a missing key.

Two accessors with different caching, and the difference matters operationally:

AccessorReturnsCache
Singular resolve (core capabilities)The primary instance30 second in-memory memo per (capability, tenant)
Plural resolve (fan-out)Every enabled, complete instanceUncached

The 30 second memo exists to kill an N+1: a request that fanned out into roughly 20 management-API calls was issuing roughly 20 identical config reads. The cache stores the in-flight promise rather than the resolved value, so concurrent callers subscribe to one read instead of stampeding the database. A failed read is evicted rather than cached, so the next caller retries.

The staleness this can cause: the write endpoint invalidates the cache in its own process immediately, but across multiple processes a config edit can take up to 30 seconds to take effect. If you change a mail credential and the very next send still uses the old one, wait out the window before assuming the edit did not land. Fan-out paths and admin/claim paths do not have this window at all, because the plural accessor is uncached on purpose: a stale list there would be worse than a query.

Secret masking

Secret fields are encrypted at rest and never returned to the client. The client sees a fixed mask sentinel in their place, so the UI can show "set" or "not set" without exposing the value.

The mask is load-bearing on write, not just on read:

  • Submitting a field still holding the mask, or left blank, preserves the stored ciphertext rather than wiping it. You can edit a base URL without re-typing the API key.
  • A required secret that is masked and has stored ciphertext counts as present, so the row does not read as incomplete.
  • On a brand-new config with nothing stored, a masked-but-never-set secret stays absent rather than being "preserved" from nothing.
  • The test-connection path builds the plaintext config directly from the submit, so it never re-encrypts a value it is about to decrypt.

DNS

DNS is a core capability with its own rules: first-claim-wins subdomain locks, an open namespace anyone may claim under, and hard protection for records Atrium does not manage. It has its own page, DNS and subdomains.

Bring your own connector

The marketplace has an Import from GitHub action: paste a public repo and Atrium fetches its manifest, runs the same validator every built-in passes, and installs it disabled pending an admin enable.

Put an atrium-connector.json at the root of a public GitHub repo. Accepted inputs are github.com/<owner>/<repo>, the <owner>/<repo> shorthand, or a raw manifest URL. A declarative connector is pure JSON, no code:

{
  "id": "acme-vault",
  "label": "Acme Vault",
  "fields": [
    { "name": "baseUrl", "label": "Base URL", "kind": "text", "secret": false, "required": true },
    { "name": "apiKey", "label": "API key", "kind": "password", "secret": true, "required": true }
  ],
  "accessLevels": ["viewer", "member", "admin"],
  "healthCheck": { "method": "GET", "url": "{baseUrl}/api/health", "expectStatus": 200 }
}

{baseUrl} in the health check is templated from the config. The importer refuses an id that collides with a built-in, so an import can never shadow a first-party connector.

The fetch itself is the other guard. Only GitHub hosts are allowed (github.com, raw.githubusercontent.com, gist.githubusercontent.com), only over https, and redirects are refused so a 3xx cannot hop off the allowlist. The manifest is capped at 64 KB, a module at 256 KB, with an 8 second timeout. Together these mean the importer cannot be pointed at an internal address or a cloud metadata endpoint.

The validator accumulates every problem rather than failing on the first, so an author fixes them all in one pass.

Three trust classes

Declarative — a form, a health check and an access-level list. No custom code, so the only risk surface is the fetch.

Brokered — ships a grant/revoke module, but never receives a credential. This is the only class a new import may be. See below.

Direct — ships a module that holds a live credential, bounded only by declaredHosts. Retained for the built-ins and for anything imported before brokering existed; refused for new imports.

The marketplace card shows the class, because that — not the vendor's reputation — is what an admin is actually approving.

Three axes: what a connector may do

A connector declares its operations in behaviour.operations. There are five, on three independent axes, and everything that has to branch — the input a module is handed, the certification run, the QA block the manifest must carry — branches on the axis, never on the operation name.

AxisOperationsActs onInput the module gets
Membershipgrant / revokea personinput.member, input.accessLevel
Lifecycleprovision / deprovisiona resource the app owns at the vendorinput.resource
Deliverynotifya messageinput.message

The axes are independent. A connector may grant members into a resource somebody else created, mint resources without managing membership, do both, or only deliver messages.

Every operation is paired with its inverse. grant without revoke is refused at import ("access that is only ever widened is the defect this contract exists to prevent"), and so is provision without deprovision (a connector that creates resources it cannot remove leaves them behind forever). notify is the single exemption, and not as a convenience: you cannot un-send a message, so there is no inverse to declare.

Declaring provision/deprovision also requires a resources block — without it the operation has nothing to act on, and what it mints can never be pinned, adopted or torn down.

Brokered: the credential never enters the code

A brokered connector calls ctx.request(method, path, body, contentType?). Atrium resolves the base URL from its own config, refuses any request outside the manifest's requests list, enforces a per-run effect ceiling, injects the credential and sends it.

An object body is JSON-encoded and sent as application/json. A string body passes through verbatim, under contentType (default text/plain; charset=utf-8) — which is there because a vendor that does not speak JSON is a fact only the connector author knows. Route 53 is the worked example: its XML body was once JSON.stringify-ed into a quoted, escaped literal, SigV4 signed that consistently, AWS authenticated it, and only then rejected the body as malformed.

{
  "baseUrlField": "url",
  "auth": { "kind": "bearer", "field": "apiKey" },
  "requests": [
    { "method": "GET",    "path": "/api/teams/{team}/members/" },
    { "method": "DELETE", "path": "/api/teams/{team}/members/{id}/" }
  ],
  "behaviour": { "entry": "grant.js", "operations": ["grant", "revoke"], "transport": "brokered" }
}

Because the key is never in the isolate, exfiltration is not bounded — it is impossible. And because every effect is declared, the reviewable artifact is that short list rather than the program. That is what makes it reasonable to import a connector from a repo nobody has read line by line.

The module's exported functions all have the same shape — fn(input, ctx), never the other way round. input is the axis payload plus config, the non-secret half of the connector's configuration; ctx carries ctx.request (or ctx.execute), ctx.config and ctx.log, and nothing else.

declaredHosts still applies (egress allowlist, and credential host-binding for direct connectors). Entries must be bare hostnames: schemes, ports, paths, wildcards and raw IPs are rejected, and the matcher fails closed.

Where the vendor's address comes from

A brokered HTTP connector must declare one of two things, and may declare both:

FieldUse it whenValidation
baseUrlFieldthe address is installation config — a self-hosted instance, a regional host an operator picksnames a non-secret field on the form
baseUrlthe address is a fact about the vendor — one fixed API endpoint for everybodymust be an absolute https URL whose host is covered by declaredHosts

baseUrlField wins when both are present, and a blank optional field falls back to the constant: a self-hosted instance is the operator's to choose, and a constant is only a default.

The constant exists because demanding a base-URL field made credential borrowing (below) impossible for exactly the vendors that need it: the built-in registry rows never carried a base URL for an API whose address was never the operator's to set, so borrowing refused for a missing value nobody could supply. Declaring it in the manifest also keeps it reviewable — it moves in the manifest diff, not in a form field.

The module is fetched from the same repo and ref as the manifest and pinned in the database, so a later repo edit cannot silently change what runs. Re-importing is the explicit update step, and it re-gates the connector whenever the re-import moves something an admin approved: the code, the declared hosts, the tier, the origin repo, or which credential it runs with.

Where the credential comes from

A connector for a vendor Atrium does not already know gets its own credential: an admin configures it, and the key lives on that connector's row.

A connector that replaces a built-in should not ask for a key Atrium already holds. Its manifest names the integration row to borrow instead:

{ "credential": { "capability": "llm-gateway", "providerId": "litellm" } }

Then one secret has one home and one rotation schedule, and the marketplace card says which row it resolved from. Resolution order is: the connector's own config if an admin set one, else the named row, else the connector refuses to run and says why — it never calls a vendor anonymously.

Both halves of the pointer are required, and both are load-bearing:

  • The capability says which registry to read.
  • The provider id selects that row, whether or not it is the capability's primary. Primacy is a question about which instance answers a platform question; it is not a question here, because the pointer already names one provider exactly. A capability that deliberately runs several providers at once (DNS is the standing example: one zone per provider) would otherwise be unborrowable from any row but the primary.

Only the fields the connector itself declares are taken from that row — a narrowing, not a copy, so borrowing can never hand the broker a key the manifest never declared.

Every refusal names the fix rather than the symptom: no stored config and no pointer says configure the connector; a pointer at a capability nobody configured names the settings page; a pointer at a provider that has no row lists the ones that do exist; a row missing required fields names the fields.

What a connector declares about itself, instead of Atrium knowing it

Three optional blocks exist so a fact about a vendor lives in that vendor's manifest rather than in a branch in the platform. Each one replaces code Atrium used to write once per vendor.

appliesTo — which groups this connector reacts to.

{ "appliesTo": { "groupKinds": ["PROJECT"] } }

Omitted means every group. The kinds are DEPARTMENT, PROJECT and AUXILIARY; they are free strings in the manifest on purpose, so naming one Atrium does not have reads as "never matches" rather than as an invalid manifest. Before this, "is this a group I care about" was re-implemented in the platform per vendor, which is why adding a vendor meant editing Atrium.

resources — what this connector owns at the vendor.

{ "resources": [
  { "kind": "team", "label": "Team", "idLabel": "team slug" },
  { "kind": "project", "label": "Project", "idLabel": "project slug", "perStack": true }
] }

kind is the vendor-side noun and must be unique within the manifest; label is required because it is what an operator reads. idLabel names what the external id actually is, so the adopt field can say "project slug" instead of "id". perStack marks a resource minted once per stack rather than once per app — the awkward case stays a declared fact instead of a branch.

Two surfaces read it: the per-app capability toggle, and the "adopt an existing resource" pin. Identity resolution itself does not change and stays generic — a pin if the operator set one, otherwise the slug derived from the app id.

icon — how the card is marked, and deliberately not a URL. See the tutorial for the two accepted forms and why a remote image on an admin-only page is a beacon.

When the vendor has no API

Some services provision by database write. Same contract, transport swapped: declare named prepared statements and call ctx.execute(name, params).

{
  "dsnField": "databaseUrl",
  "statements": [
    { "name": "delete_project_membership", "kind": "write",
      "sql": "delete from project_memberships where project_id = $1 and user_id = $2" }
  ]
}

The module names a statement; it never holds SQL text or the DSN. Parameters are bound, undeclared names are refused, the run is one transaction (rollback on failure), and declaredHosts must be empty — such a module makes no HTTP calls, so it gets no egress. A statement's SQL is fixed to the byte, making it a narrower surface than a path pattern with wildcards.

A connector declares exactly one surface: requests or statements.

When one grant at a time is the wrong unit

The run being one transaction is a property you can lose by dispatching too finely. Some vendors have no per-(member, level) seam at all: self-hosted Langfuse reconciles all of one user's project memberships inside a single transaction holding an advisory lock. Sending that as N grant calls would be N runs, so N transactions, and a partial failure would leave the user half-synced across projects — exactly what the lock exists to prevent.

So the contract has a fourth axis. reconcile carries a subject's complete desired access in one call; whatever is in the set is granted, whatever is missing is revoked.

{
  "behaviour": { "entry": "sync.js", "operations": ["reconcile"], "transport": "brokered" }
}
// input: { subject: { email }, desired: [{ accessLevel, resource? }] }

It is exempt from the "declare grant, declare revoke" rule because it contains its own inverse, and that is also how it is certified: reconcile to one level, observe it present, reconcile to the empty set, observe it gone. A connector that only ever widens access cannot pass — which is a stronger badge than the membership axis earns, where the undo is a second operation you hope is the inverse of the first.

Certification: the gate before enable

A connector that ships code cannot be enabled until it passes a QA run against the real vendor:

POST /api/admin/connectors/imported/<id>/certify
{ "config": { … }, "secrets": { … } }

The run certifies every axis the connector declares, and only those — otherwise a connector that mints resources and never grants membership would earn its badge without one of its operations being exercised. The manifest declares the throwaway subject per axis:

AxisDeclaresThe run
Membershipqa.testMember + qa.testAccessLevelgrant → observe present → revoke → observe gone
Lifecycleqa.testResource (its kind must be one of resources)provision → observe present → deprovision → observe gone
Deliveryqa.testMessagenotify → the vendor accepted it
Reconciliationqa.testMember + qa.testAccessLevelreconcile to one level → observe present → reconcile to the empty set → observe gone

Then, for every axis: every call the module made was inside the declared surface. That is the step that cannot be faked — the run goes through the same broker as production, so a module reaching outside requests fails certification for the same reason it would fail at run time.

Delivery earns a weaker badge, and the step report says so. There is no observe-gone step for notify and there cannot be one; the strongest claim available is that the vendor answered 2xx. qa.verify is required for every other axis and optional only for a delivery-only connector. Certification fails if any declared axis fails.

Declaring a QA fixture for an axis the connector does not implement is refused too, so an author is never asked to invent a fixture nothing exercises.

Reconciliation is membership's set-shaped sibling: one call carries a subject's complete desired set, and the connector makes reality match it in one transaction. It exists for vendors with no per-(member, level) seam — a partial failure must not leave a subject half-synced — and it is why reconcile needs no paired inverse: reconciling to the empty set is the undo, and that is exactly how it is certified.

Credentials are used for the run and never persisted — only the verdict, the step report and a fingerprint of the certified bytes. Enabling is refused with 409 unless a passing certification exists for the current fingerprint, so a re-import with changed code invalidates it automatically. A later failing run disables a live connector and raises a Sentry event; disabling is never gated.

Building one: Tutorial: build a connector.

Connector directory

Eleven connectors ship in the catalog today. Field-by-field setup lives on each connector's own page.

ConnectorCapabilityCorePage
PlunkNotificationsyes/docs/connectors/plunk
SMTPNotificationsyes/docs/connectors/smtp
ZitadelIdentityyes/docs/connectors/zitadel
Microsoft Entra IDDirectory (group source)yes/docs/connectors/entra
AWS Route 53DNS (app subdomains)yes/docs/connectors/route53
Cloudflare DNSDNS (app subdomains)yes/docs/connectors/cloudflare
LiteLLMLLM gatewayno/docs/connectors/litellm
SentryObservabilityno/docs/connectors/sentry
LangfuseTracingno/docs/connectors/langfuse
DokployHostingno/docs/connectors/dokploy
Microsoft AzureHostingno/docs/connectors/azure

Setting one up end to end is walked through in Tutorial: connect a connector.

What is not a connector yet

Being honest about the edges, because these names appear in group-sync badges and in older material:

  • Bitbucket is not in the connector catalog. It is configured with deploy-time environment variables (ATRIUM_BITBUCKET_WORKSPACE, ATRIUM_BITBUCKET_TOKEN, and optionally ATRIUM_BITBUCKET_USER), so it does not appear in the marketplace, has no encrypted registry row, and changing it requires a redeploy. It is a valid group-sync target, which is why you see it on group cards.
  • Lumos and Keycloak are aspirational. Neither ships in the catalog. Keycloak appears only as a worked example of why identity takes several providers; Lumos only as an illustration of the import manifest shape.
  • source-control is a declared capability with no provider behind it yet.

Status

Live today: the four core capabilities, all eleven built-in connectors, the standardized contract, the validator, Import-from-GitHub at both tiers, the sandboxed connector runtime, and the pre-enable test-run. Group to access-level mapping is the remaining active build.