Skip to content

Register providers and services

The Services container is your host’s composition root. It is where shared capabilities are constructed, where every provider is registered, and where the surfaces (HTTP, eval) reach in to find what to expose. This guide covers configuring the container, registering one or several providers, the validation that happens when you do, and how those providers are aggregated for the eval endpoint.

import { Services } from '@grundlag/core';
import { createFileSystemCreator } from '@grundlag/core/filesystem';
import { createPgliteCreator } from '@grundlag/core/pglite';
const services = new Services({
databaseCreator: createPgliteCreator((id) => `./data/db/${id}`),
bucketCreator: createFileSystemCreator((id) => `./data/buckets/${id}`),
});

The databaseCreator tells providers how to obtain their local databases. Providers that persist state ask the container for a database by id; createPgliteCreator resolves each id to a directory holding an embedded Postgres. The bucketCreator does the same for file blobs — see Store blobs in a bucket. Providers that need neither simply never ask, and a container given neither creator falls back to in-memory storage for both. See Services and dependency injection for the container’s resolution model.

Providers live in the ProviderRegistry, which you resolve from the container:

import { ProviderRegistry } from '@grundlag/core';
const providers = services.get(ProviderRegistry);

services.get is how you reach any shared capability. The registry it returns is the list of providers this host serves.

A provider package exports a provider type — a factory with a config schema. You register it with the configuration that type expects:

import { notesProvider } from '@acme/notes-provider';
await providers.register(notesProvider, {
workspace: 'personal',
});

register is asynchronous because setting a provider up may open connections or run migrations. Once it resolves, the provider’s actions and entities are live on every surface.

The second argument is parsed against the provider type’s own Zod config schema before the provider is set up. If it does not match, register throws a ProviderConfigValidationError carrying the offending providerId and the Zod issues — the provider is never constructed with bad configuration.

try {
await providers.register(notesProvider, { workspace: 42 });
} catch (err) {
// ProviderConfigValidationError: config for `notes` did not match its schema
}

Configure providers from environment variables when the values are secrets or deployment-specific, and register conditionally when a provider is optional:

const { NOTES_TOKEN } = process.env;
if (NOTES_TOKEN) {
await providers.register(notesProvider, { token: NOTES_TOKEN });
}

A host can serve as many providers as you like. Register each one — the registry keeps them distinct by id, and each provider’s actions and entities are namespaced by that id on the HTTP API.

import { notesProvider } from '@acme/notes-provider';
import { calendarProvider } from '@acme/calendar-provider';
await providers.register(notesProvider, { workspace: 'personal' });
await providers.register(calendarProvider, { timezone: 'Europe/Copenhagen' });

The HTTP surface exposes them under distinct prefixes — /api/providers/notes/... and /api/providers/calendar/... — with no coupling between them.

The registry also flattens every registered provider’s actions into one map, via toActions(). This is the single shared action layer the non-HTTP surfaces build on. The eval endpoint calls it at startup:

const actions = await providers.toActions();
// { 'notes.create': Action, 'notes.search': Action, 'calendar.createEvent': Action, ... }

Each provider contributes its actions under its own namespace, so ids stay unambiguous across providers. The eval endpoint hands this aggregated map to a sandbox, which is why a script can call actions from any registered provider. See The eval endpoint and MCP.

Everything above happens in your host entry file, before createServer. The container you built is the one you pass to createServer({ services }) — the surfaces read their providers from it. See Compose your own host for the full entry file, and Providers and registries for the registry model.