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.
Entity versus action
Section titled “Entity versus action”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.
Define an entity type
Section titled “Define an entity type”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.
The data schema
Section titled “The data schema”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 and cursor-based search
Section titled “searchInput and cursor-based search”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.
Register the entity type
Section titled “Register the entity type”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.
Entities are tools too
Section titled “Entities are tools too”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.
Guidelines
Section titled “Guidelines”- Include a string
idindata. It is required, and it is the handle every other primitive uses to reference the record. - Make
iddurable. Thegettool 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
nextCursoronly when another page exists. Omitting it signals the end of results; always returning one causes callers to page forever. - Keep
gettotal. Returnundefinedfor a missing id rather than throwing. - Model
datasemantically. 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.