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.
The file layout
Section titled “The file layout”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.tsThis 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 factory pattern
Section titled “The factory pattern”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.
The provider type
Section titled “The provider type”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.
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 };The instance subclass
Section titled “The instance subclass”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.
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.
The root and domain factories
Section titled “The root and domain factories”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.
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 };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 };A single action file
Section titled “A single action file”Each action file exports one create{Verb}Action(provider) factory. It closes over
provider, so execute can call the shared connection.
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 };Why this shape
Section titled “Why this shape”- 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 tomessages.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 viaProviderRegistry.toActions().
Related
Section titled “Related”- Define actions — the action contract and its context.
- Providers and registries — how a provider type becomes a running instance and how the host aggregates it.
- Persist state with a database — giving the provider its own database.
- Store blobs in a bucket — giving the provider its own object store for files.