Mora’s action system enables AI-driven data modifications with approval workflows and comprehensive error handling. Learn how actions are created, approved, and executed.
Action Structure
Actions are stored in the moraActions table with a complete lifecycle:
moraActions : defineTable ({
threadId: v . id ( "moraThreads" ),
status: v . string (), // "pending" | "approved" | "executed" | "rejected" | "failed"
actionType: v . string (), // "event.create", "reminder.update", etc.
payload: v . any (), // Action-specific data
preview: v . string (), // Human-readable description
requiresApproval: v . boolean (), // Whether user confirmation is needed
approvedAt: v . optional ( v . string ()),
executedAt: v . optional ( v . string ()),
error: v . optional ( v . string ()),
result: v . optional ( v . any ()),
createdAt: v . string (),
})
. index ( "by_threadId_createdAt" , [ "threadId" , "createdAt" ])
. index ( "by_threadId_status_createdAt" , [ "threadId" , "status" , "createdAt" ])
. index ( "by_status" , [ "status" ])
Action Types and Payloads
Mora supports actions across three main scopes: events , reminders , and notes .
Event Actions Create, update, or delete baby care events like feeds, sleep, and diapers
Reminder Actions Manage reminder rules, schedules, and configurations
Note Actions Add notes and observations to the timeline
Event Actions
event.create
event.update
event.delete
{
actionType : "event.create" ,
payload : {
babyId : "kg2h..." , // Optional, defaults to current baby
type : "FEED" | "SLEEP" | "DIAPER" | "MEDICINE" | ... ,
timestamp : "2024-03-05T14:30:00Z" ,
caregiverId : "kg2h..." , // Optional
payload : { // Event-specific data
amount : 120 ,
unit : "ml" ,
method : "bottle"
}
},
preview : "Log bottle feed of 120ml at 2:30 PM" ,
requiresApproval : true
}
{
actionType : "event.update" ,
payload : {
id : "kg2h..." , // Required: event ID to update
timestamp : "2024-03-05T14:45:00Z" ,
caregiverId : "kg2h..." ,
payload : {
amount : 150 , // Updated fields
unit : "ml"
}
},
preview : "Update feed event time and amount" ,
requiresApproval : true
}
{
actionType : "event.delete" ,
payload : {
id : "kg2h..." // Required: event ID to delete
},
preview : "Delete feed event from 2:30 PM" ,
requiresApproval : true
}
Reminder Actions
reminder.create
reminder.update
reminder.delete
{
actionType : "reminder.create" ,
payload : {
babyId : "kg2h..." , // Optional, defaults to current baby
category : "medication" | "feeding" | "custom" ,
title : "Vitamin D" ,
triggerType : "fixedTimes" | "afterEvent" | "interval" ,
triggerConfig : {
times : [ "09:00" , "21:00" ] // For fixedTimes
},
enabled : true ,
quietHoursStart : 22 , // Optional: 10 PM
quietHoursEnd : 7 , // Optional: 7 AM
snoozeOptions : [ 5 , 15 , 30 ] // Optional: snooze durations in minutes
},
preview : "Create vitamin reminder at 9 AM and 9 PM" ,
requiresApproval : true
}
{
actionType : "reminder.update" ,
payload : {
id : "kg2h..." , // Required: reminder ID
title : "Vitamin D (updated)" ,
enabled : false , // Disable reminder
triggerConfig : {
times : [ "09:00" ] // Change to once daily
}
},
preview : "Disable vitamin reminder" ,
requiresApproval : true
}
{
actionType : "reminder.delete" ,
payload : {
id : "kg2h..." // Required: reminder ID to delete
},
preview : "Delete vitamin reminder" ,
requiresApproval : true
}
Note Actions
{
actionType : "note.create" ,
payload : {
babyId : "kg2h..." , // Optional, defaults to current baby
text : "Baby has a slight rash on the left cheek" ,
timestamp : "2024-03-05T14:30:00Z" // Optional, defaults to now
},
preview : "Add note about rash observation" ,
requiresApproval : true
}
Notes are stored as special NOTE type events in the events table, making them part of the timeline.
Approval Workflow
Actions follow a state machine with approval gates:
Creating Pending Actions
export const createPendingMoraAction = mutation ({
args: {
threadId: v . id ( "moraThreads" ),
actionType: v . string (),
payload: v . any (),
preview: v . string (),
requiresApproval: v . boolean (),
},
handler : async ( ctx , args ) => {
const user = await requireAuth ( ctx );
const thread = await ctx . db . get ( args . threadId );
if ( ! thread ) throw new Error ( "Thread not found" );
if ( thread . babyId ) {
await requireBabyAccess ( ctx , thread . babyId , user . _id );
}
const now = new Date (). toISOString ();
const id = await ctx . db . insert ( "moraActions" , {
... args ,
status: args . requiresApproval ? "pending" : "approved" ,
approvedAt: args . requiresApproval ? undefined : now ,
createdAt: now ,
});
await ctx . db . patch ( args . threadId , { lastMessageAt: now });
return await ctx . db . get ( id );
},
});
Approving Actions
export const approveMoraAction = mutation ({
args: { actionId: v . id ( "moraActions" ) },
handler : async ( ctx , args ) => {
const { action } = await verifyMoraActionAccess ( ctx , args . actionId );
if ( action . status !== "pending" ) return action ;
const now = new Date (). toISOString ();
await ctx . db . patch ( args . actionId , {
status: "approved" ,
approvedAt: now
});
return await ctx . db . get ( args . actionId );
},
});
Rejecting Actions
export const rejectMoraAction = mutation ({
args: {
actionId: v . id ( "moraActions" ),
reason: v . optional ( v . string ()),
},
handler : async ( ctx , args ) => {
await verifyMoraActionAccess ( ctx , args . actionId );
const action = await ctx . db . get ( args . actionId );
if ( ! action ) throw new Error ( "Action not found" );
await ctx . db . patch ( args . actionId , {
status: "rejected" ,
error: args . reason ?? "Rejected by user" ,
});
return await ctx . db . get ( args . actionId );
},
});
Listing Pending Actions
const pendingActions = useQuery ( api . mora . listPendingMoraActions , {
threadId ,
});
Execution and Error Handling
Once approved, actions are executed with comprehensive validation and error handling.
Execution Flow
export const executeApprovedMoraAction = mutation ({
args: {
actionId: v . id ( "moraActions" ),
},
handler : async ( ctx , args ) => {
const { user } = await verifyMoraActionAccess ( ctx , args . actionId );
const action = await ctx . db . get ( args . actionId );
if ( ! action ) throw new Error ( "Action not found" );
// Verify action can be executed
if ( ! [ "approved" , "pending" ]. includes ( action . status )) {
throw new Error ( `Action status ${ action . status } cannot be executed` );
}
// Check settings
const settings = await getResolvedSettings ( ctx );
if ( ! settings . enabled ) throw new Error ( "Mora is disabled" );
if ( ! settings . allowWrites ) throw new Error ( "Mora writes are disabled" );
// Verify scope permission
const scope = inferScope ( action . actionType );
if ( scope === "unknown" || ! settings . allowedWriteScopes . includes ( scope )) {
throw new Error ( `Action scope not allowed: ${ scope } ` );
}
const payload = ( action . payload ?? {}) as Record < string , any >;
const now = new Date (). toISOString ();
let entityId : string | undefined ;
// Execute based on action type
if ( action . actionType === "event.create" ) {
const babyId = payload . babyId ??
( await getLatestBabyProfileIdForUser ( ctx , user . _id ));
if ( ! babyId ) throw new Error ( "No baby profile available" );
entityId = await ctx . db . insert ( "events" , {
babyId ,
type: payload . type ,
timestamp: payload . timestamp ?? now ,
caregiverId: payload . caregiverId ,
payload: payload . payload ?? {},
source: "mora" , // Track AI-created events
createdAt: now ,
});
}
// ... handle other action types
const result = {
status: "executed" as const ,
actionId: action . _id ,
entityId ,
summary: ` ${ action . actionType } executed` ,
};
await ctx . db . patch ( action . _id , {
status: "executed" ,
executedAt: now ,
result ,
error: undefined ,
});
return result ;
},
});
Scope Inference
function inferScope ( actionType : string ) : "events" | "reminders" | "notes" | "unknown" {
if ( actionType . startsWith ( "event." )) return "events" ;
if ( actionType . startsWith ( "reminder." )) return "reminders" ;
if ( actionType === "note.create" ) return "notes" ;
return "unknown" ;
}
Error States
Actions can fail for various reasons:
export const markMoraActionFailed = mutation ({
args: {
actionId: v . id ( "moraActions" ),
error: v . string (),
},
handler : async ( ctx , args ) => {
await verifyMoraActionAccess ( ctx , args . actionId );
await ctx . db . patch ( args . actionId , {
status: "failed" ,
error: args . error ,
});
return await ctx . db . get ( args . actionId );
},
});
Always handle action failures gracefully and provide clear error messages to users.
Real Action Examples
Example 1: Creating a Feed Event
User Input : “Log a 120ml bottle feed at 2:30 PM”
Mora Creates Action :
{
threadId : "kg2h..." ,
status : "pending" ,
actionType : "event.create" ,
payload : {
type : "FEED" ,
timestamp : "2024-03-05T14:30:00.000Z" ,
payload : {
amount : 120 ,
unit : "ml" ,
method : "bottle"
}
},
preview : "Log bottle feed of 120ml at 2:30 PM" ,
requiresApproval : true ,
createdAt : "2024-03-05T14:32:15.000Z"
}
After Approval & Execution :
{
status : "executed" ,
approvedAt : "2024-03-05T14:32:20.000Z" ,
executedAt : "2024-03-05T14:32:21.000Z" ,
result : {
status : "executed" ,
actionId : "kg2h..." ,
entityId : "kg2h...new-event-id" ,
summary : "event.create executed"
}
}
Example 2: Updating a Reminder
User Input : “Change the vitamin reminder to 8 AM”
Mora Creates Action :
{
threadId : "kg2h..." ,
status : "pending" ,
actionType : "reminder.update" ,
payload : {
id : "kg2h...existing-reminder" ,
triggerConfig : {
times : [ "08:00" ]
}
},
preview : "Update vitamin reminder to 8:00 AM" ,
requiresApproval : true ,
createdAt : "2024-03-05T15:10:00.000Z"
}
Example 3: Adding a Note
User Input : “Note: Baby seems fussy after breastfeeding”
Mora Creates Action :
{
threadId : "kg2h..." ,
status : "pending" ,
actionType : "note.create" ,
payload : {
text : "Baby seems fussy after breastfeeding" ,
timestamp : "2024-03-05T16:20:00.000Z"
},
preview : "Add note: Baby seems fussy after breastfeeding" ,
requiresApproval : true ,
createdAt : "2024-03-05T16:20:05.000Z"
}
Approval Card Component
Actions requiring approval are displayed using the MoraApprovalCard component:
src/components/MoraApprovalCard.tsx
interface MoraApprovalCardProps {
action : any ;
onAfterAction ?: () => void ;
}
export default function MoraApprovalCard ({
action ,
onAfterAction
} : MoraApprovalCardProps ) {
const [ busy , setBusy ] = useState < "approve" | "reject" | null >( null );
const approve = useMutation ( api . mora . approveMoraAction );
const reject = useMutation ( api . mora . rejectMoraAction );
const execute = useMutation ( api . mora . executeApprovedMoraAction );
const createMessage = useMutation ( api . mora . createMoraMessage );
const handleApprove = async () => {
setBusy ( "approve" );
try {
await approve ({ actionId: action . _id });
const result = await execute ({ actionId: action . _id });
await createMessage ({
threadId: action . threadId ,
role: "assistant" ,
parts: [{ type: "text" , text: result . summary }],
text: result . summary ,
});
onAfterAction ?.();
} finally {
setBusy ( null );
}
};
const handleReject = async () => {
setBusy ( "reject" );
try {
await reject ({
actionId: action . _id ,
reason: "Rejected in Mora sidebar"
});
await createMessage ({
threadId: action . threadId ,
role: "assistant" ,
parts: [{ type: "text" , text: `Action rejected: ${ action . preview } ` }],
text: `Action rejected: ${ action . preview } ` ,
});
onAfterAction ?.();
} finally {
setBusy ( null );
}
};
const riskTone =
action . actionType . includes ( "delete" )
? "text-alert-red bg-alert-red/8"
: action . actionType . includes ( "update" )
? "text-night bg-night/5"
: "text-sage bg-sage/8" ;
return (
< div className = "rounded-2xl border bg-white p-4 shadow-sm" >
< div className = "flex items-start justify-between gap-3 mb-3" >
< div >
< div className = "text-sm font-semibold" > Approval Required </ div >
< div className = "text-xs text-muted mt-1" > { action . preview } </ div >
</ div >
< span className = { `px-2 py-1 rounded-full text-[10px] ${ riskTone } ` } >
{ action . actionType . split ( "." )[ 1 ] ?? "action" }
</ span >
</ div >
< pre className = "text-[11px] bg-oat/60 rounded-xl p-3 overflow-x-auto" >
{ JSON . stringify ( action . payload , null , 2 ) }
</ pre >
< div className = "mt-3 flex justify-end gap-2" >
< Button variant = "secondary" onClick = { handleReject } disabled = { busy !== null } >
{ busy === "reject" ? "Rejecting..." : "Reject" }
</ Button >
< Button onClick = { handleApprove } disabled = { busy !== null } >
{ busy === "approve" ? "Applying..." : "Approve & Apply" }
</ Button >
</ div >
</ div >
);
}
Security and Permissions
Access Control
All actions respect the same permissions as manual operations:
async function verifyMoraActionAccess (
ctx : Parameters < typeof requireAuth >[ 0 ],
actionId : Id < "moraActions" >
) {
const user = await requireAuth ( ctx );
const action = await ctx . db . get ( actionId );
if ( ! action ) throw new Error ( "Action not found" );
const thread = await ctx . db . get ( action . threadId );
if ( ! thread ) throw new Error ( "Thread not found" );
if ( thread . babyId ) {
await requireBabyAccess ( ctx , thread . babyId , user . _id );
}
return { user , action };
}
Settings Enforcement
Actions are gated by Mora settings:
const settings = await getResolvedSettings ( ctx );
if ( ! settings . enabled ) throw new Error ( "Mora is disabled" );
if ( ! settings . allowWrites ) throw new Error ( "Mora writes are disabled" );
const scope = inferScope ( action . actionType );
if ( ! settings . allowedWriteScopes . includes ( scope )) {
throw new Error ( `Action scope not allowed: ${ scope } ` );
}
Audit Trail
All Mora-created records are tagged:
{
source : "mora" , // Identifies AI-generated content
createdAt : now ,
updatedAt : now // For updates
}
Use the source field to filter or highlight AI-generated events in your UI.
Best Practices
Always Provide Clear Previews
The preview field should be a concise, human-readable description of what the action will do. This is what users see in the approval card.
Include All Required Fields
Validate payloads before creating actions. Missing required fields will cause execution to fail.
Always catch and handle execution errors. Update the action status to failed and store the error message.
When yoloMode is enabled, set requiresApproval: false for faster execution. For destructive actions, consider always requiring approval.
Ensure action types correctly map to scopes. This enables fine-grained permission control through allowedWriteScopes.
Testing Actions
Test action execution in isolation:
import { convexTest } from "convex-test" ;
import { api } from "./_generated/api" ;
import schema from "./schema" ;
describe ( "Mora Actions" , () => {
it ( "creates and executes an event action" , async () => {
const t = convexTest ( schema );
// Create thread
const threadId = await t . mutation ( api . mora . getOrCreateMoraThread , {});
// Create action
const action = await t . mutation ( api . mora . createPendingMoraAction , {
threadId ,
actionType: "event.create" ,
payload: {
type: "FEED" ,
timestamp: new Date (). toISOString (),
payload: { amount: 120 , unit: "ml" }
},
preview: "Log feed" ,
requiresApproval: true ,
});
expect ( action . status ). toBe ( "pending" );
// Approve and execute
await t . mutation ( api . mora . approveMoraAction , { actionId: action . _id });
const result = await t . mutation ( api . mora . executeApprovedMoraAction , {
actionId: action . _id
});
expect ( result . status ). toBe ( "executed" );
expect ( result . entityId ). toBeDefined ();
});
});
Next Steps
Overview Learn about Mora’s capabilities and settings
Chat Interface Understand threads, messages, and context