Skip to content

Expose entities

An entity type describes a searchable kind of resource your provider owns — contacts, calendar events, notes, tasks. Where an action is a verb (“create event”, “send message”), an entity is a noun: something a caller discovers, selects, and later references by id. This guide covers defining one and registering it.

Reach for an entity when the caller needs to find or retrieve a record, not cause an effect. A calendar provider exposes events as an entity so an agent can search for “my meeting with Sam” and then hand the returned id to a createReminder action. The entity owns discovery and retrieval; actions do the work. See Primitives for how the four primitives divide up a provider’s surface.

Use defineEntityType. Every entity type needs a stable id, a human-readable name and description, a data schema, a searchInput schema, and the two operations search and get.

import { defineEntityType, z } from '@grundlag/core';
const calendarEvent = defineEntityType({
id: 'calendarEvent',
name: 'Calendar event',
description: 'An event on the user’s calendar.',
data: z.object({
id: z.string(),
title: z.string(),
startsAt: z.string().datetime(),
location: z.string().optional(),
}),
searchInput: z.object({
query: z.string().min(1).describe('Free-text match against the event title.'),
after: z.string().datetime().optional().describe('Only events starting after this time.'),
}),
search: async ({ input, limit, cursor, services, logger }) => {
logger.info('Searching calendar events', { query: input.query });
const page = await calendar.search(input.query, {
after: input.after,
limit,
cursor,
});
return {
results: page.items,
// Only set nextCursor when another page exists.
nextCursor: page.hasMore ? page.cursor : undefined,
};
},
get: async ({ id, services, logger }) => {
return calendar.getEvent(id);
},
});

z is re-exported from the core package, so you do not need a separate zod import.

data describes a single record. It must be a z.object that includes a string id — that field is the handle every other primitive uses to refer to the record. Model the fields callers actually need to reason over; keep them semantic (startsAt: string.datetime(), not three separate day/month/year fields). Use .describe() on fields that benefit from it — those descriptions flow into generated tool definitions and docs.

searchInput is the schema for the query a caller supplies. search receives that parsed input alongside the shared context and returns { results, nextCursor? }:

Field Type Description
input inferred from searchInput The parsed query.
limit number | undefined Maximum number of results to return for this page.
cursor string | undefined Opaque position from a previous page; absent on the first page.
services Services The host dependency container.
logger Logger A provider-safe logger.

Pagination is cursor-based. On the first call cursor is undefined; return up to limit results and, only when another page exists, a nextCursor. The caller passes that value back as cursor on the next call. When there are no more results, omit nextCursor entirely — returning one implies another page is available and the caller will keep asking.

The cursor is opaque to the caller, so encode whatever your backend needs (an offset, a timestamp, a token). Treat limit as advisory: honour it as an upper bound.

get retrieves a single record by its id and returns the record or undefined when nothing matches. It receives { id, services, logger }. Keep get cheap and total — it is how callers resolve an id they obtained from search (or from an earlier action) back into full data.

An entity type becomes discoverable once it is registered on a ProviderInstance:

provider.entities.register(calendarEvent);

register accepts one or more entity types and keys them by id, so a later registration with the same id replaces the earlier one. The Structure a provider package guide shows the factory pattern real providers use to organise many entity types cleanly.

Registering an entity type also gives it two agent-facing tools, alongside its HTTP routes: {providerId}_{entityId}_search and {providerId}_{entityId}_get. They come out of ProviderRegistry.toTools() next to the action tools, so a sandbox script or an agent can call them directly:

const notes = await acmeNoteSearch({ query: 'passport', limit: 5 });
const note = await acmeNoteGet({ id: notes.results[0].id });

This is why an agent can work at all: actions are verbs, and without entity tools there is no way to find the record whose id an action needs.

The search tool’s input is your searchInput fields with cursor and limit folded in alongside them{ query: 'passport', limit: 5 }, not a nested wrapper. That flattening is why searchInput must be a z.object(...) rather than any schema. It also means a cursor or limit field of your own would be overwritten, so name search fields something else.

The get tool returns { record }, nullable — absence is a value rather than a failure, because asking about something that turns out not to exist is a normal answer.

toActions() stays actions-only. It backs the HTTP action routes, and entities already have their own routes there; widening it would publish every entity twice. Over HTTP and in the typed client, entities remain their own surface — only the flat tool namespace merges the two.

  • Include a string id in data. It is required, and it is the handle every other primitive uses to reference the record.
  • Make id durable. The get tool is a promise that an id can be held onto and resolved later. A record whose id is regenerated when its content changes does not belong in an entity — expose that through an action returning a transient projection instead.
  • Return nextCursor only when another page exists. Omitting it signals the end of results; always returning one causes callers to page forever.
  • Keep get total. Return undefined for a missing id rather than throwing.
  • Model data semantically. Expose the fields a caller reasons over, with .describe() where it clarifies intent.
  • Use an entity for nouns, an action for verbs. Discovery and retrieval belong to the entity; effects belong to actions.