Skip to content

Build a provider

In this lesson you build a notes provider from an empty file, register it in a host, start the server, and call both of its actions with curl. By the end you will have a working provider and understand the shape every provider follows.

This is a single happy path. When you want to go deeper on any step, the provider guides cover each topic on its own.

Have the toolchain from Installation ready, and install the two packages this lesson uses:

Terminal window
pnpm add @grundlag/core @grundlag/server

Create a working file called notes.ts — everything in this tutorial goes there.

A provider is packaged as a provider type: a factory with an id, a name, a Zod config schema, and a setup function that returns a ProviderInstance. Start with the smallest possible config — a dataDir string telling the provider where it may keep its notes.

notes.ts
import { defineProviderType, ProviderInstance, z } from '@grundlag/core';
const notesProvider = defineProviderType({
id: 'notes',
name: 'Notes',
config: z.object({
dataDir: z.string().min(1),
}),
setup: async ({ config }): Promise<ProviderInstance> => {
const provider = new ProviderInstance('notes');
// actions get registered here in the next step
return provider;
},
});

setup receives the validated config and a services container. Whatever you register on the returned instance is everything the platform can see.

Actions are the capabilities the provider exposes. Define one with defineAction, giving it a stable id, a name, and Zod schemas for input and output. For this lesson, keep the notes in an in-memory array so the code stays focused.

import { defineAction } from '@grundlag/core';
type Note = { id: string; title: string; body: string };
const notes: Note[] = [];
const createNote = defineAction({
id: 'createNote',
name: 'Create note',
description: 'Stores a new note and returns its id.',
input: z.object({
title: z.string().min(1),
body: z.string(),
}),
output: z.object({
id: z.string(),
}),
execute: async ({ input, logger }): Promise<{ id: string }> => {
const note: Note = { id: crypto.randomUUID(), ...input };
notes.push(note);
logger.info('Created note', { id: note.id });
return { id: note.id };
},
});

The runtime parses the request against input before execute runs and against output after it returns, so you never validate by hand. See Define actions for the full contract.

A second action reads the notes back. Its input can be empty; its output is an array.

const listNotes = defineAction({
id: 'listNotes',
name: 'List notes',
description: 'Returns every stored note.',
input: z.object({}),
output: z.object({
notes: z
.object({
id: z.string(),
title: z.string(),
body: z.string(),
})
.array(),
}),
execute: async (): Promise<{ notes: Note[] }> => {
return { notes };
},
});

Register both actions on the instance inside setup. register takes a variadic list, so you can pass them together.

setup: async ({ config }): Promise<ProviderInstance> => {
const provider = new ProviderInstance('notes');
provider.actions.register(createNote, listNotes);
return provider;
},

A host is an application you write. Create a Services container, get the ProviderRegistry from it, register your provider type with its config, and start the server. register validates the config against your schema and throws a ProviderConfigValidationError if it does not match.

import { ProviderRegistry, Services } from '@grundlag/core';
import { createServer } from '@grundlag/server';
const services = new Services();
const providers = services.get(ProviderRegistry);
await providers.register(notesProvider, { dataDir: './data' });
const server = await createServer({ services });
await server.listen({ port: 3800 });
console.log('Listening on http://localhost:3800');
Terminal window
pnpm dlx tsx notes.ts

You should see the listening message. (You can also compile with tsc and run with node — see Installation.)

Each action is a POST endpoint at /api/providers/notes/actions/:actionId. Create a note:

Terminal window
curl -X POST http://localhost:3800/api/providers/notes/actions/createNote \
-H 'content-type: application/json' \
-d '{ "title": "Groceries", "body": "milk, eggs, bread" }'
{ "id": "b1c9…" }

Then list them back:

Terminal window
curl -X POST http://localhost:3800/api/providers/notes/actions/listNotes \
-H 'content-type: application/json' \
-d '{}'
{ "notes": [{ "id": "b1c9…", "title": "Groceries", "body": "milk, eggs, bread" }] }

Open http://localhost:3800/api/docs to see both actions documented with their schemas, ready to try from the browser.

  • A provider type with a validated config schema.
  • Two actionscreateNote and listNotes — with typed input and output.
  • A host that registered the provider and served its actions as a typed HTTP API.

The same provider would run unchanged inside an agent or the sandbox — nothing in it knows how it is being consumed.

Your provider kept everything in memory and exposed only actions. Real providers do more: