Skip to main content
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:
convex/schema.ts
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

{
  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
}

Reminder Actions

{
  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
}

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

convex/mora.ts
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

convex/mora.ts
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

convex/mora.ts
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

convex/mora.ts
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

convex/mora.ts
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:
convex/mora.ts
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:
convex/mora.ts
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

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.
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

Build docs developers (and LLMs) love