Engagements
Calls, emails, meetings, notes and tasks logged on one or more records, with an owner, a due time and an outcome, a record's timeline, its next step and a member's open tasks.
engagements logs the work people do around a record: a call with its outcome and duration, an email, a meeting, a note, or a task with a due time. One engagement can sit on several records at once, such as a call on a deal, its company and the person on the line, so each record's panel shows it. Any record registered in config.records can carry engagements.
This block is the logged work, not the system history. The activity feed records what the system did (a stage change, a comment), and every engagement event carries its main record, so the feed shows both on one timeline.
Reading needs engagements.read, and logging needs engagements.write. The author and the owner edit an engagement, and engagements.manage edits anyone's. Admins get every permission, and members get engagements.read and engagements.write.
better-supabase add activitiesThe block's entity is engagement (the activities table). Installing it registers engagement as a subject, searchable by title, body and outcome when the search module is installed. Rename it with blocks.activities.names, for example { engagement: "touchpoint" }. engagementInputSchema and engagementChangeSchema validate what log and update take.
The module needs tenant, permissions and subjects.
import { createActivities } from "better-supabase/blocks/activities";
const engagements = createActivities({ transport });
const deal = { type: "deal", id: dealId };
await engagements
.log(
organizationId,
{
type: "call",
title: "Discovery call",
outcome: "connected",
durationSeconds: 900,
doneAt: Temporal.Now.instant(),
},
[deal, { type: "person", id: personId }],
)
.orThrow();
await engagements
.log(
organizationId,
{ type: "task", title: "Send proposal", dueAt: friday },
[deal],
)
.orThrow();Log and change
log(organizationId, input, subjects) needs a type and a title. The first record in subjects is the engagement's main record, which its events carry. Every record must exist in the organization and be readable by the caller (ENGAGEMENT_SUBJECT_FORBIDDEN). The owner defaults to the caller, and assigning it to someone else notifies them when the notifications module is installed.
options.types lists the types (default call, email, meeting, note, task; ENGAGEMENT_TYPE_INVALID for another). An externalRef, such as a message id from a mail provider or an event id from a calendar, is unique per organization: logging it again updates the engagement, so a sync can replay.
update(id, input, subjects?) changes the keys you pass (null clears one) and replaces the records when you pass subjects. complete(id, { outcome }) marks it done now, and { done: false } opens it again. remove(id) deletes it.
Installing the block registers engagements as the engagement subject, so the attachments block keeps files on a task, a call or an email: upload to { type: "engagement", id: task.id } and list them with the same subject. Only people who can read the engagement can add or see its files.
Your own fields
blocks.activities.options.extraColumns adds columns to the activities table,
keyed by column name with their SQL type. log and update write them from fields, and every engagement comes back with them under fields. Pass a
Standard Schema as fields to type them,
parse them on reads and validate them on writes; a bad value is a
validation error before anything reaches the database (see
extending blocks):
import { z } from "zod";
const engagements = createActivities({
transport,
fields: z.object({ region: z.enum(["emea", "amer", "apac"]) }),
});
await engagements.log(
organizationId,
{ type: "call", title: "Intro", fields: { region: "emea" } },
subjects,
);Bookings
With the bookings block installed, set blocks.activities.options.bookings: true and every confirmed booking becomes a meeting engagement. A trigger on the bookings table keeps it in step, so bookings made through the public link or by a direct write are logged too.
export default defineConfig({
blocks: {
bookings: {},
contacts: {},
activities: { options: { bookings: true } },
},
});| Booking | Engagement |
|---|---|
| Confirmed | A meeting titled with the service and the customer's name, due at the start, with the notes as its body and the length in durationSeconds |
| Rescheduled while confirmed | The same meeting moves to the new time and opens again |
| Cancelled after confirming | The meeting is done at the cancel time, with the outcome cancelled: <reason> |
The meeting's externalRef is booking:<id>, so it is one engagement per booking, and code that logs the same booking with that ref updates it instead of adding a second one. With parties installed, the meeting sits on the person whose user or email (in any case) matches the booker's, and on that person's company (the primary link first). A booker with no contact gets a meeting on no record, which every reader of the organization sees. The owner is the member who made the booking, else the contact's owner. It needs meeting in options.types, and installing it without the bookings block fails at render time. The events are the usual engagement.logged, engagement.updated and engagement.completed.
Read
| Method | Returns |
|---|---|
of(subject, { types, before, limit }) | A record's engagements, newest first: done ones by when they happened, open ones by when they are due |
next(subject) | The open engagement with the earliest due time, or undefined: a record's next step |
open(organizationId, { ownerId, dueBefore }) | A member's open engagements, earliest due first; the caller's by default |
Each engagement has overdue: open and past its due time. Reads run as the caller: an engagement is visible to members who can read one of its records, or to every reader of the organization when it is on none.
Recordings and notes
blocks.activities.options.recordings (off by default) stores call and meeting recordings, their transcripts and notes about an engagement. It needs the ai-files block, which holds the audio or video, and adds the engagement_recordings, engagement_transcript_segments and engagement_notes tables. The block imports no speech or AI SDK (ADR 0010): you pass a Transcriber and a NoteTaker that wrap the provider you use.
import { createActivities } from "better-supabase/blocks/activities";
const engagements = createActivities({
transport,
service: serviceTransport,
files: aiFiles,
transcriber: {
apiVersion: 1,
model: "my-stt",
transcribe: async ({ url, language }, { signal } = {}) => {
const result = await speechToText(url, { language, signal });
return {
language: result.language,
segments: result.turns.map((turn) => ({
speaker: turn.speaker,
startMs: turn.start,
endMs: turn.end,
text: turn.text,
})),
};
},
},
noteTaker: {
apiVersion: 1,
model: "my-model",
summarize: async ({ engagement, transcript }) =>
summarizeCall(engagement, transcript),
},
});
const recording = await engagements
.attachRecording(engagementId, fileId, { language: "en" })
.orThrow();attachRecording(engagementId, fileId) links a ready ai-files file of the same organization: the author, the owner and engagements.manage may attach one, and the file must be the caller's unless they hold engagements.manage. The recording starts pending. With the jobs block installed it queues a transcription on engagements.transcribe; register transcribeJob() as that queue's handler, and its last attempt marks the recording failed with the error. Without jobs, call transcribe(recordingId) yourself or run drainRecordings() on a schedule as the service role.
transcribe signs the file's URL and reads its bytes through files, calls the transcriber and saves its speaker turns. saveTranscript(recordingId, transcript) stores a transcript you already have, such as one a meeting tool exports; saving again replaces it. Each segment has a speaker label, an optional speakerUserId, startMs and endMs from the start of the recording, its text and an optional confidence.
summarize(engagementId) gives the note taker the engagement and every transcript turn and saves its summary and action items as the engagement's notes (source: "ai", with the model). saveNotes(engagementId, { summary, actionItems }) saves notes a member wrote (source: "member"), replacing earlier ones.
| Method | Returns |
|---|---|
recordings(engagementId) | The engagement's recordings, oldest first, with their status |
transcript(recordingId, { after, limit }) | Speaker turns in order, 500 by default and 5000 at most |
notes(engagementId) | The engagement's notes, or undefined |
Recordings, transcripts and notes are readable by whoever can read the engagement. Teammates see the filename and media type without access to the file itself. testTranscriber and testNoteTaker from better-supabase/testing check that your wrappers keep the contract.
Events
| Event | Data |
|---|---|
engagement.logged, engagement.updated, engagement.completed, engagement.deleted | organizationId, engagementId, type, subjectType, subjectId, subjects |
engagement.recorded, engagement.transcribed | The same, and recordingId |
engagement.summarized | The same as engagement.logged |
Hooks
The module calls before_engagement_log(tenant, attrs jsonb, subjects jsonb) and after_engagement_log(engagement uuid). A before_ hook raises to refuse the log. The TypeScript client takes hooks for its methods (see extending blocks).
Wire it to ReUI
The ReUI CRM Activity Timeline reads of(subject) and groups the rows into day buckets by doneAt or dueAt; its type tabs pass types. The Deal Desk composer calls log with the deal and its contact as subjects, and the Forecast Call's "No next step" and "Overdue activity" chips read next(deal) and its overdue.
Errors
| Hint | Cause |
|---|---|
ENGAGEMENT_FORBIDDEN | the caller lacks engagements.write |
ENGAGEMENT_TYPE_INVALID | not a configured type |
ENGAGEMENT_TITLE_REQUIRED | the title is empty |
ENGAGEMENT_SUBJECTS_INVALID | subjects isn't a list of { type, id } |
ENGAGEMENT_SUBJECT_FORBIDDEN | a record is unknown, in another organization or unreadable |
ENGAGEMENT_NOT_FOUND | no engagement the caller can edit |
AI_FILE_NOT_FOUND | no ready file of the organization the caller may attach |
ENGAGEMENT_RECORDING_EXISTS | the file is already a recording of the engagement |
ENGAGEMENT_RECORDING_NOT_FOUND | no such recording |
ENGAGEMENT_TRANSCRIPT_INVALID | a segment lacks its text or ends before it starts |
ENGAGEMENT_NOTES_INVALID | an empty summary or an action item without text |
ENGAGEMENT_NO_TRANSCRIBER, ENGAGEMENT_NO_FILES, ENGAGEMENT_NO_NOTE_TAKER | createActivities lacks the option the method needs |
ENGAGEMENT_TRANSCRIBE_FAILED, ENGAGEMENT_SUMMARIZE_FAILED | the transcriber or note taker threw |
Last updated on
Parties
Companies and people an organization works with, with lifecycle stages, owners, who works where with a role and a primary contact, dedupe by domain and email, merges and keyset-paged search.
Mail sync
Sync each member's Gmail or Outlook mailbox through their own OAuth grant, match threads to people and companies, log them as email engagements and send replies.