Skip to content

Providers and registries

A provider is how one external system — a messaging service, a calendar, a notes store — becomes something the platform can use. Understanding providers means separating two things that are easy to conflate: the type that describes a provider, and the instance that exists at runtime.

Types are factories; instances are the runtime object

Section titled “Types are factories; instances are the runtime object”

A provider type is a factory. It carries a stable id, a human name, a Zod schema for its configuration, and a setup function:

import { defineProviderType, ProviderInstance, z } from '@grundlag/core';
const messagingProvider = defineProviderType({
id: 'messaging',
name: 'Messaging',
config: z.object({ apiKey: z.string() }),
setup: async ({ services, config }) => {
const instance = new ProviderInstance('messaging');
instance.actions.register(sendMessage);
return instance;
},
});

The type is static and describes nothing about a running connection. It says what config a provider needs and how to bring one to life. A provider instance is the result of running setup — a ProviderInstance that owns three registries making up its public surface: actions, events, and entities. Whatever setup registers there is everything the platform can see; connection handling, caching, and API calls stay private behind it.

A fourth registry, buckets, is the exception that proves the rule. Registering a bucket declares it rather than publishes it — it makes the provider’s blob storage known before first use, which a host needs in order to mount routes at boot, but the bucket stays private until its definition says exposed. See Store blobs in a bucket.

The distinction matters because one type can produce many instances. The same messaging type, given two different API keys, yields two independent instances with the same capabilities pointed at different accounts.

How ProviderRegistry brings a type to life

Section titled “How ProviderRegistry brings a type to life”

ProviderRegistry is where types become instances. When you register(type, config) it:

  1. Parses config against the type’s schema. Invalid config throws a ProviderConfigValidationError — carrying the providerId and Zod issues — and setup never runs.
  2. Calls setup({ services, config }) with the parsed config and the shared Services container, and holds onto the resulting instance.

Because validation happens before setup, a misconfigured provider fails loudly at registration rather than halfway through its first call. The registry passes the same Services container to every provider, which is how providers reach shared capabilities — a database, the provider registry itself — without importing anything host-specific. See Services and dependency injection.

Consumers — the agent, the sandbox, the HTTP API — do not want to walk a list of providers and drill into each one’s action registry. They want a flat map of callable actions. toActions() produces exactly that: it lists every registered provider, flattens all their actions into one Record<string, Action>, and namespaces each key by its provider so that messaging + sendMessage becomes a single safe function name. Collisions are deduplicated. The result is one map the consumer can turn into tool definitions, script bindings, or HTTP routes without knowing which provider anything came from.

This is the payoff of the type/instance split: the registry is the seam between “a set of configured providers” and “a namespace of actions”, and every consumer meets the platform on the far side of that seam.

ProviderTypeRegistry is the simpler cousin. It holds the types a host knows how to create — register, list, get by id — without instantiating any of them. A host uses it to advertise which providers are available to configure (say, in a UI that offers a catalogue), then hands a chosen type and its config to a ProviderRegistry to actually set up. Types are the menu; the provider registry is the kitchen.

Every registry on the platform — actions, events, entities, providers, types — exposes the same small vocabulary: register, list, get. They are deliberately behaviour-oriented rather than plain maps. A caller states what it wants to do (register this, list those, fetch by id) and the registry decides how, which lets it enforce invariants at the boundary: ActionRegistry rejects a duplicate id with ActionAlreadyRegisteredError; ProviderRegistry validates config before setup. A bare map could not do that, and consumers depending on the map’s shape would couple to storage detail instead of behaviour. The uniform surface also means learning one registry teaches you all of them.

A provider imports from @grundlag/core and nothing else — never the server, never an agent. Everything a provider needs is a core contract: the define* builders, the registries it populates, the Services container it reaches capabilities through. This is what lets the same provider run unchanged inside an HTTP host, an agent, and a sandbox. The dependency arrow points one way, down to core, and the runtime that hosts the provider stays on the far side of the contracts. See Architecture for how the layers fit together and Primitives for what a provider exposes.