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.
Before you start
Section titled “Before you start”Have the toolchain from Installation ready, and install the two packages this lesson uses:
pnpm add @grundlag/core @grundlag/serverCreate a working file called notes.ts — everything in this tutorial goes there.
1. Define the provider type
Section titled “1. Define the provider type”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.
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.
2. Add the createNote action
Section titled “2. Add the createNote action”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.
3. Add the listNotes action
Section titled “3. Add the listNotes action”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 }; },});4. Register the actions in setup
Section titled “4. Register the actions in setup”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;},5. Compose a host
Section titled “5. Compose a host”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');6. Run it
Section titled “6. Run it”pnpm dlx tsx notes.tsYou should see the listening message. (You can also compile with tsc and run with node —
see Installation.)
7. Invoke both actions
Section titled “7. Invoke both actions”Each action is a POST endpoint at /api/providers/notes/actions/:actionId. Create a note:
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:
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.
What you built
Section titled “What you built”- A provider type with a validated config schema.
- Two actions —
createNoteandlistNotes— 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.
Next steps
Section titled “Next steps”Your provider kept everything in memory and exposed only actions. Real providers do more:
- Persist state with a database — replace the in-memory array with a provider-local database.
- Expose entities — make notes searchable resources consumers can discover and fetch by id.
- Publish events — emit a signal when a note is created so automations can react.
- Structure a provider package — the file layout and factory pattern for a provider with many actions.