Skip to content

Invites: composing & extending

From a vertical

The engine exports its entity registry as invitesEntities; pass it to your defineOperations(entities, PERMISSIONS, [invitesEntities]) so an operation that narrows to, or emits about, an invitation names the engine's entity rather than retyping it (see Composing engines).

Call the exported in-scope functions inside your own operation, in the same transaction, having checked your own permission first:

ts
import { sendInvite } from '@substrat-run/engine-invites';

const inviteColleagueOp: OperationHandler<{ email: string }, { id: string }> =
  async (ctx, input) => {
    assertAllowed(await ctx.check(MYAPP_PERM.manageTeam));
    return sendInvite(ctx, {
      orgId: myOrgFor(ctx),
      identifier: input.email,
      roleKey: 'colleague',   // YOUR vocabulary
    });
  };

The engine never decides who may invite, what a role means, or how the invitation reaches the recipient. Those are the vertical's.

Delivering the invitation

The engine records that an invitation exists; it does not send email. That is deliberate — delivery is an effect on the outside world, so it belongs in a connector, not in module code (which cannot reach the network at all).

Consume invites.sent in a connector and send whatever your product sends. The event carries no identifier, so the connector resolves the recipient from your own records — which keeps the address out of the spine.

A worked example

The shape a vertical takes around it: compose sendInvite inside your own <vertical>/invite-<person> operation, keep the invitee's name and party ref in your own table keyed by the invitation id, and create your membership row from a consumer on invites.accepted. No demo in the repo composes this engine today — the racket-club demo that did was removed — so the engine's own suite (engines/invites/test/) is the working reference for each call.

Extending it

Extra states belong in your vertical as substates, not as forks of the machine. The engine's four states are about whether the offer stands; anything about your onboarding flow is yours.

Extra data on an invitation goes in your own table keyed by the invitation id. The engine's table is private, as every module's is.

What not to do

Do not report whether an address is already invited or already a member. Every affordance that distinguishes those cases turns the invite surface into a membership oracle. If your UI wants to say "already on the team", derive it from your own membership list for people the caller can already see — never from the invite path.

The hard parts, hosted.