How to Build a Mica Workflow

How to leverage Mica (Monterosa Interaction Cloud Assistant) to build your own AI powered automations

This guide walks you through building a Mica AI Assistant Workflow using AWS Lambda. We'll create the "Spot the Ball" demo workflow as an example, though the same principles apply to any interactive workflow.

About the Spot the Ball Demo

Mica UI in action

"Spot the Ball" is an AI-powered workflow that automates the creation of interactive trivia content. This process leverages AI to:

  • Remove the ball from a sports photograph

  • Generate multiple choice answer options

  • Create an engaging trivia question format

This automation saves valuable content creation time by eliminating the need for manual photo editing and question generation. What traditionally might take 30 minutes per image can now be accomplished in seconds.

Example showing original image with ball → AI processing → image without ball + multiple choice overlay

Prerequisites

  • Familiarity with AWS Lambda basics

  • Understanding of JavaScript/Node.js

  • Access to AWS Console

Overview

A Mica Workflow is an HTTP endpoint that creates interactive dialogues within the Monterosa / Studio UI. The workflow receives POST requests with user context and responds with JSON that defines the UI blocks and available actions.

Key concepts:

  • Stateless communication: Each request is independent

  • Payload persistence: Data carries forward between requests

  • Block-based UI: Response defines visual components

  • Action-driven flow: User interactions trigger new requests

Step 1: Set Up the Lambda Function

Create a new AWS Lambda function with the following configuration:

  • Runtime: Node.js (latest version)

  • Create a Lambda function URL to make it publicly accessible

  • Set CORS headers to allow Studio to communicate with your endpoint

Step 2: Implement Authentication

Every workflow must authenticate requests to prevent unauthorised access. The authentication flow:

  1. Extract the Authorisation header from the request

  2. Use the token to call the Studio API's /api/v2/me endpoint

  3. Verify the user has appropriate permissions for the requested resource

// NOTE: 
// This is a shortened version of the function, refer to the full listing below
// to get the full one with logging and error handling.
async function getAuthenticatedUser(event) {
  const authHeader = event.headers?.Authorization || event.headers?.authorization;
  const requestBody = JSON.parse(event.body);
  const baseURL = requestBody.context?.baseURL;
  const res = await fetch(`${baseURL}/api/v2/me`, {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
      Authorization: authHeader,
    },
  });
  if (!res.ok) return null;
  const data = await res.json();
  return data?.data;
}

For role-based authorisation, use the /api/v2/{resource}/{id}/my_role endpoints as described in the Authentication and authorisation section.

Step 3: Handle the Initial Request

When a user opens your workflow, Studio sends an initial POST request with no action. This is where you return the first screen:

// No action = initial screen
if (!action) {
  return {
    blocks: [
      {
        type: "input",
        input_type: "image",
        label: "Upload image",
        name: "upload_image",
      },
      // ... more input blocks
    ],
    actions: [
      {
        type: "button",
        label: "Generate",
        action_id: "generate_images",
      },
    ],
  };
}

Step 4: Define Block Types

Blocks are the building components of your workflow UI. Common types include:

  • Text blocks: Display information with basic HTML formatting

  • Input blocks: Collect user data (text, images, colours, files)

  • Image blocks: Show images with optional labels

  • Loader blocks: Indicate processing status

  • Alert blocks: Show status messages

See the Blocks section for complete specifications.

Step 5: Handle User Actions

When users interact with your workflow (button clicks), Studio sends a new request with the action field populated:

switch (action) {
  case "generate_images":
    // Process the user inputs
    const optionsCount = parseInt(inputs.options_count) || 3;
    return {
      blocks: [
        // Show generated results
      ],
      actions: [
        { type: "button", label: "Regenerate", action_id: "regenerate_images" },
        { type: "button", label: "Create", action_id: "create_experience" },
      ],
      payload: {
        // Store data for next request
        hero_image_url: heroImageUrl,
        options_count: optionsCount,
      },
    };
}

Step 6: Use Payload for State Management

Since workflows are stateless, use the payload object to maintain data between requests. Any data in the response payload is included in the next request:

// Store data in response
payload: {
  attempt: attemptNumber,
  hero_image_url: heroImageUrl,
  user_inputs: inputs,
}

// Access in next request
const previousData = payload?.hero_image_url;

Step 7: Handle Long-Running Operations

For time-intensive operations like image processing, use the task-based polling mechanism:

// Start a long-running task
return {
  blocks: [
    { type: "loader", text: "Processing..." }
  ],
  task: {
    id: "task_123",
    state: "running",
    progress: 45,
    pollIntervalMs: 2000
  }
};

See the Tasks section for complete implementation details.

Step 8: Structure Your Response

Every workflow response must follow this structure:

{
  blocks: Block[],           // UI components to render
  actions?: Action[],        // Available user actions
  autoAction?: AutoAction,   // Automatic action to trigger
  payload?: object,          // Data to persist
  task?: Task                // Long-running operation status
}

Step 9: Add Error Handling

Always handle authentication failures and invalid requests gracefully:

const authUser = await getAuthenticatedUser(event);
if (!authUser) {
  return {
    statusCode: 200,
    body: JSON.stringify({
      blocks: [
        {
          type: "text",
          text: "You are not authorised to use this workflow. Please log in.",
        },
      ],
    }),
  };
}

Step 10: Submit for Review

Once your workflow is complete:

  1. Ensure your Lambda function URL is publicly accessible

  2. Submit your workflow name and endpoint URL to the Monterosa team

  3. The workflow will be reviewed using the Verification and approval checklist

Key Implementation Tips

  • Always authenticate first: Check user authorisation before processing any requests

  • Use descriptive action IDs: Make your code self-documenting

  • Handle edge cases: Account for missing inputs or invalid states

  • Keep responses lightweight: Only include necessary data in blocks

  • Maintain payload consistency: Ensure data flows correctly between requests

Beyond the Example

While this guide uses the "Spot the Ball" workflow as an example, you can build workflows for any interactive process:

  • Content summarisation

  • Data analysis and visualisation

  • Multi-step form wizards

  • Approval workflows

  • Integration with external APIs

The key is understanding the request/response cycle and building appropriate UI blocks for your use case.

Complete Code Example

// Set up CORS headers
const corsHeaders = {
  "Access-Control-Allow-Origin": "*", // Allow requests from any origin
  "Access-Control-Allow-Headers":
    "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
  "Access-Control-Allow-Methods": "OPTIONS,POST,GET",
};

export const handler = async (event) => {
  console.log("Received event:", JSON.stringify(event, null, 2));
  
  // Handle preflight OPTIONS request
  if (event.httpMethod === "OPTIONS") {
    return {
      statusCode: 200,
      headers: corsHeaders,
      body: JSON.stringify({}),
    };
  }
  
  const authUser = await getAuthenticatedUser(event);
  if (!authUser) {
    return generateResponse(200, {
      blocks: [
        {
          type: "text",
          text: "You are not authorised to use this workflow. Please log in.",
        },
      ],
    });
  }
  
  let action = "";
  let inputs = {};
  let payload = {};
  
  if (event.body) {
    const requestBody = JSON.parse(event.body);
    action = requestBody.action || "";
    inputs = requestBody.inputs || {};
    payload = requestBody.payload || {};
  }
  
  // If no action is provided, return the initial workflow UI
  if (!action) {
    return generateResponse(200, {
      blocks: [
        {
          type: "input",
          input_type: "image",
          label: "Upload image",
          name: "upload_image",
        },
        {
          type: "input",
          input_type: "text",
          label: "Number of answer options",
          name: "options_count",
        },
        {
          type: "input",
          input_type: "colour",
          label: "Label colour",
          name: "label_colour",
        },
      ],
      actions: [
        {
          type: "button",
          label: "Generate",
          action_id: "generate_images",
        },
      ],
    });
  }
  
  switch (action) {
    case "generate_images":
    case "regenerate_images": {
      const previousHeroImageUrl = payload?.hero_image_url;
      const { heroImageUrl, revealImageUrl } =
        getRandomImagePair(previousHeroImageUrl);
      const attemptNumber = payload?.attempt ? payload.attempt + 1 : 1;
      const optionsCount = parseInt(inputs.options_count) || 3;
      
      return generateResponse(200, {
        blocks: [
          {
            type: "text",
            label: "Question",
            text: "Can you spot the ball?",
            format: "plain",
          },
          {
            type: "image",
            label: "Hero image",
            url: heroImageUrl,
            alt_text: "Hero Image",
          },
          {
            type: "image",
            label: "Reveal image",
            url: revealImageUrl,
            alt_text: "Reveal Image",
          },
          {
            type: "text",
            label: "Correct answer",
            text: `Option ${Math.floor(Math.random() * optionsCount) + 1}`,
            format: "plain",
          },
        ],
        actions: [
          {
            type: "button",
            label: "Regenerate",
            action_id: "regenerate_images",
          },
          { type: "button", label: "Create", action_id: "create_experience" },
        ],
        payload: {
          attempt: attemptNumber,
          hero_image_url: heroImageUrl,
          reveal_image_url: revealImageUrl,
          // Store the original inputs to maintain them across requests
          upload_image: inputs.upload_image,
          options_count: inputs.options_count,
          label_colour: inputs.label_colour,
        },
      });
    }
    
    case "create_experience": {
      // This action only returns a loading state screen.
      // In a real implementation, you would use the payload data to create the experience.
      console.log(
        "Creating experience with payload:",
        JSON.stringify(payload, null, 2),
      );
      
      return generateResponse(200, {
        blocks: [
          {
            type: "text",
            text: "Building your experience...",
            format: "plain",
          },
          {
            type: "loader",
            text: "Please wait while we prepare your experience.",
          },
          {
            type: "image",
            label: "Using hero image",
            url: payload.hero_image_url,
            alt_text: "Hero Image",
          },
          {
            type: "image",
            label: "Using reveal image",
            url: payload.reveal_image_url,
            alt_text: "Reveal Image",
          },
          {
            type: "text",
            label: "Number of options",
            text: payload.options_count,
            format: "plain",
          },
          {
            type: "text",
            label: "Label colour",
            text: payload.label_colour,
            format: "plain",
          },
        ],
      });
    }
    
    default:
      return generateResponse(400, { error: "Unknown action" });
  }
};

// Helper function to generate a response
function generateResponse(statusCode, body, headers = {}) {
  return {
    statusCode,
    headers: {
      "Content-Type": "application/json",
      ...corsHeaders,
    },
    body: JSON.stringify(body),
  };
}

// Helper function to confirm the request is authenticated.
// Returns the user ID and user object if authenticated, otherwise null.
async function getAuthenticatedUser(event) {
  try {
    const authHeader =
      event.headers?.Authorization || event.headers?.authorization;
    if (!authHeader) {
      console.log("No auth header found");
      return null;
    }
    
    if (!event.body) {
      console.log("Missing request body");
      return null;
    }
    
    const requestBody = JSON.parse(event.body);
    const baseURL = requestBody.context?.baseURL;
    
    if (!baseURL) {
      console.log("No base URL found, cannot authenticate user");
      return null;
    }
    
    const res = await fetch(`${baseURL}/api/v2/me`, {
      method: "GET",
      headers: {
        "Content-Type": "application/json",
        Authorization: authHeader,
      },
    });
    
    if (!res.ok) {
      console.log("Failed checking user authentication");
      return null;
    }
    
    const data = await res.json();
    const user = data?.data;
    const userId = user?.id || user?.attributes?.email || null;
    
    if (!userId) {
      console.log("No user ID found in response");
      return null;
    }
    
    console.log(`User authenticated successfully: ${userId}`);
    return { userId, user };
  } catch (error) {
    console.log("Error checking user authentication", error);
    return null;
  }
}

// Returns a random image pair from the mock image URLs.
// Could be replaced with a real image generation API call.
// The image provided by the user is ignored in this demo.
function getRandomImagePair(previousHeroImageUrl) {
  const mockImageUrls = [
    "https://cdn-int24.lvis.io/assets/84/84d940aa-b031-4004-b524-b4fe8848ec96/orig",
    "https://cdn-int24.lvis.io/assets/f7/f7c25766-e4d6-4dd7-8cfd-e2b3547241b2/orig",
    "https://cdn-int24.lvis.io/assets/f0/f003fdfe-c520-46bc-b11e-30aae5459ea0/orig",
    "https://cdn-int24.lvis.io/assets/6f/6f6dee09-50e2-400e-bcbf-8b1dd9023ef7/orig",
  ];
  
  const mockRevealUrls = [
    "https://cdn-int24.lvis.io/assets/44/44cf6897-7b1f-43fd-b881-fb0eae0a7fe9/orig",
    "https://cdn-int24.lvis.io/assets/2e/2e7431e9-1073-42f5-ba8d-bf104b520b5c/orig",
    "https://cdn-int24.lvis.io/assets/70/70003aad-b5f4-4c55-aba3-33f799f55620/orig",
    "https://cdn-int24.lvis.io/assets/8b/8b838e96-dc60-4eef-8cf6-d547412a746e/orig",
  ];
  
  let index = Math.floor(Math.random() * mockImageUrls.length);
  
  // Make sure we don't get the same image as the previous one for better demo.
  while (
    mockImageUrls[index] === previousHeroImageUrl &&
    mockImageUrls.length > 1
  ) {
    index = Math.floor(Math.random() * mockImageUrls.length);
  }
  
  return {
    heroImageUrl: mockImageUrls[index],
    revealImageUrl: mockRevealUrls[index],
  };
}

If you've followed the steps above, you should now be ready to build and deploy your own Workflow.

Last updated