Skip to content

Structure a provider package

A one-file provider is fine for a tutorial, but a real provider adapts a whole external system — messaging, calendar, a device network — and quickly grows dozens of actions. This guide shows the layout and factory pattern that keep that manageable, and how a provider shares one connection or service across all of its actions.

It assumes you know how to define an action and how a provider type produces a ProviderInstance.

Group actions by domain (the subsystems of whatever you are adapting) and keep one file per action. A provider that adapts a system with messages and contacts looks like this:

src/
provider/
provider.ts # defineProviderType + config schema
provider.instance.ts # ProviderInstance subclass, owns the connection
schemas/
schemas.ts # shared Zod schemas
database/
database.ts # provider-local database definition
migrations/
migrations.001-init.ts
buckets/
buckets.ts # provider-local bucket definitions
actions/
actions.ts # root factory → Action[]
messages/
messages.ts # domain group factory
messages.send.ts # one action
messages.list.ts
contacts/
contacts.ts
contacts.search.ts

This mirrors the repo conventions: no index.ts, a module’s public API is {module}/{module}.ts, support files are {module}/{module}.{area}.ts, and file names are kebab-case. See the coding standards enforced by ESLint — type over interface, arrow functions only, # private fields, explicit return types.

The key idea: each action is created by a factory function that closes over the provider instance, so it can reach the provider’s shared connection, service, or database. Factories compose bottom-up — one per action, one per domain group, one root — and the instance calls the root factory in its constructor.

provider/provider.ts holds the config schema and hands the validated config to the instance subclass. Config flows ProviderRegistry.register(type, config) → Zod validation → type.setup({ services, config }) → your instance constructor.

src/provider/provider.ts
import { defineProviderType, z } from '@grundlag/core';
import { MessagingProvider } from './provider.instance.js';
const messagingProvider = defineProviderType({
id: 'messaging',
name: 'Messaging',
config: z.object({
url: z.url(),
token: z.string().min(1),
}),
setup: async ({ config, services }) => {
const instance = new MessagingProvider({ ...config, services });
await instance.ready();
return instance;
},
});
export { messagingProvider };

provider/provider.instance.ts subclasses ProviderInstance. It keeps the connection and config in private fields, registers every action in its constructor, and exposes the methods actions need. The connection is created lazily and cached, so setup stays cheap and repeated calls share one connection.

src/provider/provider.instance.ts
import { ProviderInstance, Services } from '@grundlag/core';
import { createActions } from '../actions/actions.js';
type MessagingProviderOptions = {
services: Services;
url: string;
token: string;
};
class MessagingProvider extends ProviderInstance {
#options: MessagingProviderOptions;
#connectionPromise?: Promise<Connection>;
constructor(options: MessagingProviderOptions) {
super('messaging');
this.#options = options;
this.actions.register(...createActions(this));
}
#connect = async (): Promise<Connection> => {
const { url, token } = this.#options;
return openConnection(url, token);
};
public ready = async (): Promise<void> => {
await this.getConnection();
};
public getConnection = async (): Promise<Connection> => {
if (!this.#connectionPromise) {
this.#connectionPromise = this.#connect();
}
return this.#connectionPromise;
};
}
export { MessagingProvider };

Because the instance owns the connection and the services container, actions never open their own connections or reach into the host — they call the methods the provider exposes. If the provider persists data, it reaches its database through services.get(DatabaseService).getInstance(...); see Persist state with a database. File blobs work the same way through BucketService — see Store blobs in a bucket.

actions/actions.ts is the single place the instance calls. It fans out to one factory per domain, and each domain factory returns its own actions.

src/actions/actions.ts
import { type Action } from '@grundlag/core';
import { type MessagingProvider } from '../provider/provider.instance.js';
import { createMessagesActions } from './messages/messages.js';
import { createContactsActions } from './contacts/contacts.js';
const createActions = (provider: MessagingProvider): Action[] => [
...createMessagesActions(provider),
...createContactsActions(provider),
];
export { createActions };
src/actions/messages/messages.ts
import { type Action } from '@grundlag/core';
import { type MessagingProvider } from '../../provider/provider.instance.js';
import { createMessagesSendAction } from './messages.send.js';
import { createMessagesListAction } from './messages.list.js';
const createMessagesActions = (provider: MessagingProvider): Action[] => [
createMessagesSendAction(provider),
createMessagesListAction(provider),
];
export { createMessagesActions };

Each action file exports one create{Verb}Action(provider) factory. It closes over provider, so execute can call the shared connection.

src/actions/messages/messages.send.ts
import { defineAction, z } from '@grundlag/core';
import { type MessagingProvider } from '../../provider/provider.instance.js';
const createMessagesSendAction = (provider: MessagingProvider) =>
defineAction({
id: 'sendMessage',
name: 'Send message',
description: 'Sends a message to a recipient.',
input: z.object({
to: z.string().min(1),
body: z.string().min(1),
}),
output: z.object({ messageId: z.string() }),
execute: async ({ input }): Promise<{ messageId: string }> => {
const connection = await provider.getConnection();
const messageId = await connection.send(input.to, input.body);
return { messageId };
},
});
export { createMessagesSendAction };
  • One connection, many actions. The instance creates the connection once; every action reaches it through a provider method. Config validated by the provider type flows into the private fields the connection is built from — actions never touch config or the host directly.
  • Adding an action is local. Write messages.{verb}.ts, add its factory to messages.ts. The root factory and the instance constructor never change.
  • Stable ids, provider namespace. Keep action ids short and verb-like (sendMessage, search). The host supplies the surrounding provider namespace when it aggregates actions via ProviderRegistry.toActions().