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:
| Surface | Path | Who |
|---|---|---|
| Marketplace (browse, Add) | /marketplace | Any 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 capability | Why it is special | Providers today |
|---|---|---|
| Notifications | How the platform reaches people, transactional mail today | Plunk, SMTP |
| Identity | The IdP: your login and grant store | Zitadel |
| Directory | Where group membership is mastered | Microsoft Entra ID |
| DNS | The platform claims app subdomains in it | AWS 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.
- Identity: an
id, alabel, a capability, an icon. - Credential: the fields to collect, each marked
secretif it must be encrypted at rest, plus an optional declarative health check so Test connection can prove the credential works. - 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:
| Kind | How Atrium uses it | Which instance acts |
|---|---|---|
| Generic access connector | One-way push. Atrium provisions access outward and holds no opinion about the far side | All of them. Every enabled instance receives the push |
| Core capability | Two-way. Atrium asks a question and needs one answer | The 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:
| Situation | Result |
|---|---|
| Row is disabled | Never primary |
| Operator ticked "make this the default" | That row becomes primary |
| First enabled instance of a capability | Adopts primacy automatically, so connecting a single provider needs no second step |
| Second instance connects | Never steals primacy from the first |
| Primary is disabled or deleted | Oldest enabled instance succeeds it, by creation time ascending |
| Nothing enabled is left | No 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:
| Accessor | Returns | Cache |
|---|---|---|
| Singular resolve (core capabilities) | The primary instance | 30 second in-memory memo per (capability, tenant) |
| Plural resolve (fan-out) | Every enabled, complete instance | Uncached |
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.
| Axis | Operations | Acts on | Input the module gets |
|---|---|---|---|
| Membership | grant / revoke | a person | input.member, input.accessLevel |
| Lifecycle | provision / deprovision | a resource the app owns at the vendor | input.resource |
| Delivery | notify | a message | input.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:
| Field | Use it when | Validation |
|---|---|---|
baseUrlField | the address is installation config — a self-hosted instance, a regional host an operator picks | names a non-secret field on the form |
baseUrl | the address is a fact about the vendor — one fixed API endpoint for everybody | must 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:
| Axis | Declares | The run |
|---|---|---|
| Membership | qa.testMember + qa.testAccessLevel | grant → observe present → revoke → observe gone |
| Lifecycle | qa.testResource (its kind must be one of resources) | provision → observe present → deprovision → observe gone |
| Delivery | qa.testMessage | notify → the vendor accepted it |
| Reconciliation | qa.testMember + qa.testAccessLevel | reconcile 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.
| Connector | Capability | Core | Page |
|---|---|---|---|
| Plunk | Notifications | yes | /docs/connectors/plunk |
| SMTP | Notifications | yes | /docs/connectors/smtp |
| Zitadel | Identity | yes | /docs/connectors/zitadel |
| Microsoft Entra ID | Directory (group source) | yes | /docs/connectors/entra |
| AWS Route 53 | DNS (app subdomains) | yes | /docs/connectors/route53 |
| Cloudflare DNS | DNS (app subdomains) | yes | /docs/connectors/cloudflare |
| LiteLLM | LLM gateway | no | /docs/connectors/litellm |
| Sentry | Observability | no | /docs/connectors/sentry |
| Langfuse | Tracing | no | /docs/connectors/langfuse |
| Dokploy | Hosting | no | /docs/connectors/dokploy |
| Microsoft Azure | Hosting | no | /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 optionallyATRIUM_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-controlis 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.