Best practices

Best practices

This guide covers patterns and practices for building reliable, user-friendly flows.

When to use a function step

Code in the body of the flow runs multiple times as the flow executes. If you need work to happen exactly once, use a function step.

Keep function steps short and focused. This makes error handling and retries work on small units of work rather than large operations that might partially complete.

As a general rule, wrap these in function steps:

  • External API calls
  • Database writes
  • Sending notifications (emails, SMS, webhooks)
  • Any operation that takes more than a few seconds
// Good: API call wrapped in a function step
const customer = await ctx.step("fetch customer from CRM", async () => {
  return await crmApi.getCustomer(inputs.customerId);
});
 
// Bad: API call in the flow body - will execute on every pass
const customer = await crmApi.getCustomer(inputs.customerId);

Reading data from the database

You have two options for reading data, and the right choice depends on your use case.

Read in a function step (snapshot data)

The data is read once and persisted. If the flow restarts, the same data is used. This is useful when you need to make decisions based on the state at a specific point in time.

const order = await ctx.step("get order snapshot", async () => {
  return await models.order.findOne({ id: inputs.orderId });
});
 
// Later in the flow, 'order' contains the data from when this step ran,
// even if the actual order has changed in the database

Read in the flow body (fresh data)

The data is read on every pass of the flow. This is useful for displaying current information to users or validating that conditions still hold.

// Read fresh data every time
const order = await models.order.findOne({ id: inputs.orderId });
 
// Validate current state before proceeding
if (order.status !== OrderStatus.Pending) {
  throw new Error("Order is no longer pending");
}
⚠️

Be careful about reading data in a function step and then writing it back later. The data could become stale during the flow's execution. If you read in a step, validate the current state before writing.

Writing data to the database

Always use a function step for database writes. This ensures the write happens exactly once, even if the flow runs multiple times.

// Good: Database write in a function step
await ctx.step("update order status", async () => {
  await models.order.update(
    { id: order.id },
    { status: OrderStatus.Processing }
  );
});
 
// Bad: Database write in the flow body - could execute multiple times
await models.order.update(
  { id: order.id },
  { status: OrderStatus.Processing }
);

Using flow inputs vs UI inputs

Flow inputs (defined in the schema) are for programmatic triggering. UI inputs are for capturing data from users during the flow.

flow RefundOrder {
  inputs {
    orderId ID  // Passed when triggering from a Tool link
  }
  @permission(roles: [Manager])
}

Use schema inputs when:

  • Linking from a Tool in the console (the order ID is passed automatically)
  • Triggering flows programmatically
  • You need the value before the flow starts

Use UI inputs when:

  • You need the user to provide or confirm information
  • The required data depends on earlier steps in the flow
  • You want to show context before capturing input
// Schema input: passed when flow starts
const orderId = inputs.orderId;
 
// UI input: captured during the flow
const { reason, amount } = await ctx.ui.page("refund details", {
  title: "Refund Details",
  content: [
    ctx.ui.inputs.text("reason", { label: "Reason for refund" }),
    ctx.ui.inputs.number("amount", { label: "Refund amount" }),
  ],
});

Designing stages for good UX

Stages show progress in the sidebar. Group related steps into logical stages rather than creating a stage for every step.

export default ProcessReturn({
  stages: [
    { name: "verify", title: "Verify Return" },
    { name: "process", title: "Process Refund" },
    { name: "complete", title: "Complete" },
  ],
}, async (ctx, inputs) => {
  // Multiple steps can belong to the same stage
  const order = await ctx.ui.page("select order", {
    stage: "verify",
    // ...
  });
 
  const items = await ctx.ui.page("select items", {
    stage: "verify",  // Same stage as above
    // ...
  });
 
  // Processing happens in the "process" stage
  await ctx.step("process refund", { stage: "process" }, async () => {
    // ...
  });
});

Tips for good stage design:

  • 3-5 stages is usually right. More than that and the sidebar becomes cluttered.
  • Name stages from the user's perspective: "Select Order" not "Step 1"
  • Group validation and confirmation into a single stage
  • Put all processing steps into one "Processing" stage

Designing pages

Each page should have a clear purpose. Don't cram too many inputs onto one page.

Use titles and descriptions

Always provide a title. Add a description when the page needs context.

await ctx.ui.page("confirm refund", {
  title: "Confirm Refund",
  description: "Review the refund details before processing. This action cannot be undone.",
  content: [
    // ...
  ],
});

Show context with display elements

Before asking for input, show relevant information so users can make informed decisions.

await ctx.ui.page("select order", {
  title: "Process Refund",
  content: [
    // Show context first
    ctx.ui.display.keyValue({
      data: [
        { label: "Customer", value: customer.name },
        { label: "Order Total", value: ${order.total}` },
        { label: "Order Date", value: order.createdAt.toLocaleDateString() },
      ],
    }),
    ctx.ui.display.divider(),
    // Then ask for input
    ctx.ui.inputs.text("reason", { label: "Refund reason" }),
  ],
});

Use banners for warnings and errors

Draw attention to important information with banners.

const content = [
  ctx.ui.display.keyValue({ data: orderDetails }),
];
 
// Add warning if order is old
if (daysSinceOrder > 30) {
  content.unshift(
    ctx.ui.display.banner({
      title: "Outside refund window",
      description: "This order is more than 30 days old. Manager approval may be required.",
      mode: "warning",
    })
  );
}
 
await ctx.ui.page("review order", {
  title: "Review Order",
  content,
});

Validation patterns

Page-level validation

Use the validate function for cross-field validation.

await ctx.ui.page("transfer stock", {
  title: "Stock Transfer",
  content: [
    ctx.ui.inputs.number("quantity", { label: "Quantity to transfer" }),
    ctx.ui.inputs.text("sourceLocation", { label: "From location" }),
    ctx.ui.inputs.text("targetLocation", { label: "To location" }),
  ],
  validate: async (data) => {
    if (data.sourceLocation === data.targetLocation) {
      return "Source and target locations must be different";
    }
    if (data.quantity <= 0) {
      return "Quantity must be greater than zero";
    }
    return true;
  },
});

Validate before processing

Always validate the current state before making changes, especially if time has passed since the user started the flow.

// User selected an order earlier in the flow...
 
// Before processing, validate it's still valid
const currentOrder = await models.order.findOne({ id: selectedOrderId });
if (!currentOrder) {
  throw new Error("Order no longer exists");
}
if (currentOrder.status !== OrderStatus.Pending) {
  throw new Error(`Order status changed to ${currentOrder.status} and can no longer be refunded`);
}
 
// Now safe to process
await ctx.step("process refund", async () => {
  // ...
});

Handling errors gracefully

Use banners to show recoverable errors

For errors the user can fix, show a banner and let them try again.

let customerEmail = inputs.email;
let customer = null;
 
while (!customer) {
  const lookup = await ctx.ui.page("find customer", {
    title: "Find Customer",
    content: [
      customerEmail && !customer
        ? ctx.ui.display.banner({
            title: "Customer not found",
            description: `No customer found with email "${customerEmail}". Please try again.`,
            mode: "error",
          })
        : null,
      ctx.ui.inputs.text("email", {
        label: "Customer email",
        defaultValue: customerEmail,
      }),
    ].filter(Boolean),
  });
 
  customerEmail = lookup.email;
  customer = await models.customer.findOne({ email: customerEmail });
}

Fail early for unrecoverable errors

If something is fundamentally wrong, fail immediately with a clear message.

export default ProcessReturn(async (ctx, inputs) => {
  const order = await models.order.findOne({ id: inputs.orderId });
 
  if (!order) {
    throw new Error(`Order ${inputs.orderId} not found`);
  }
 
  if (order.status === OrderStatus.Refunded) {
    throw new Error("This order has already been refunded");
  }
 
  // Continue with the flow...
});

Idempotency and safety

Function steps run at most once, but you should still design for safety.

Use unique identifiers for external operations

When calling external APIs, use idempotency keys to prevent duplicate operations.

await ctx.step("create refund", async () => {
  const stripe = require("stripe")(ctx.secrets.STRIPE_KEY);
 
  // Use order ID as idempotency key - if this step retries,
  // Stripe will return the existing refund instead of creating a new one
  return await stripe.refunds.create(
    { payment_intent: order.paymentIntentId },
    { idempotencyKey: `refund-${order.id}` }
  );
});

Check state before and after

Validate conditions both before starting work and after completing it.

// Check before
const order = await models.order.findOne({ id: orderId });
if (order.status !== OrderStatus.Pending) {
  throw new Error("Order is not pending");
}
 
// Do the work
const result = await ctx.step("process payment", async () => {
  return await paymentProvider.charge(order.total);
});
 
// Check after (in case something else modified the order)
const updatedOrder = await models.order.findOne({ id: orderId });
if (updatedOrder.status !== OrderStatus.Pending) {
  // Payment was processed but order status changed - log and handle
  console.error(`Order ${orderId} status changed during payment`);
  await ctx.step("refund duplicate", async () => {
    return await paymentProvider.refund(result.transactionId);
  });
  throw new Error("Order status changed during processing. Payment has been refunded.");
}

Long-running flows

Flows can run for days or weeks. Design for this reality.

Don't assume data is current

Data captured early in the flow may be stale by the time you use it.

// Captured days ago
const selectedItems = await ctx.ui.page("select items", { /* ... */ });
 
// Days later, when the flow continues...
// Re-fetch and validate each item
for (const item of selectedItems.items) {
  const currentItem = await models.stockItem.findOne({ id: item.id });
  if (!currentItem || currentItem.quantity < item.requestedQuantity) {
    throw new Error(`Stock for ${item.name} is no longer available`);
  }
}

Show timestamps and freshness

When displaying data, show when it was fetched so users know how current it is.

const stockLevels = await ctx.step("fetch stock", async () => {
  const items = await models.stockItem.findMany({});
  return {
    items,
    fetchedAt: new Date().toISOString(),
  };
});
 
await ctx.ui.page("review stock", {
  title: "Stock Levels",
  description: `Data as of ${new Date(stockLevels.fetchedAt).toLocaleString()}`,
  content: [
    ctx.ui.display.table({
      data: stockLevels.items.map(item => ({
        SKU: item.sku,
        Name: item.name,
        Quantity: item.quantity,
      })),
    }),
  ],
});

Testing during development

Use console logging

Log key decision points to help debug flows.

console.log(`Processing order ${order.id}, status: ${order.status}`);
 
const result = await ctx.step("calculate refund", async () => {
  const amount = calculateRefundAmount(order);
  console.log(`Calculated refund amount: ${amount}`);
  return amount;
});

Test with edge cases

Create test data that exercises edge cases:

  • Orders with no items
  • Customers with special characters in names
  • Very large or very small monetary amounts
  • Items that become unavailable during the flow

Test the unhappy path

Make sure your error handling works by deliberately triggering errors:

  • Pass invalid IDs
  • Modify data between steps to simulate concurrent changes
  • Test what happens when external APIs are unavailable

Common ERP patterns

Order fulfillment

export default FulfillOrder({
  stages: [
    { name: "pick", title: "Pick Items" },
    { name: "pack", title: "Pack Order" },
    { name: "ship", title: "Ship" },
  ],
}, async (ctx, inputs) => {
  // Validate order can be fulfilled
  const order = await models.order.findOne({ id: inputs.orderId });
  if (order.status !== OrderStatus.Confirmed) {
    throw new Error("Order must be confirmed before fulfillment");
  }
 
  // Pick stage: confirm each item is picked
  const lines = await models.orderLine.findMany({
    where: { orderId: { equals: order.id } },
  });
 
  for (const line of lines) {
    await ctx.ui.page(`pick-${line.id}`, {
      stage: "pick",
      title: `Pick: ${line.productName}`,
      content: [
        ctx.ui.display.keyValue({
          data: [
            { label: "Product", value: line.productName },
            { label: "Quantity", value: line.quantity.toString() },
            { label: "Location", value: line.location },
          ],
        }),
        ctx.ui.inputs.boolean("picked", {
          label: "Item picked",
          defaultValue: false,
        }),
      ],
    });
  }
 
  // Pack stage
  const { weight, boxSize } = await ctx.ui.page("pack", {
    stage: "pack",
    title: "Pack Order",
    content: [
      ctx.ui.inputs.number("weight", { label: "Package weight (kg)" }),
      ctx.ui.select.one("boxSize", {
        label: "Box size",
        data: [
          { value: "small", label: "Small" },
          { value: "medium", label: "Medium" },
          { value: "large", label: "Large" },
        ],
      }),
    ],
  });
 
  // Ship stage: get tracking number from carrier
  const shipment = await ctx.step("create shipment", async () => {
    return await carrierApi.createShipment({
      orderId: order.id,
      weight,
      boxSize,
      address: order.shippingAddress,
    });
  });
 
  // Update order with tracking
  await ctx.step("update order", async () => {
    await models.order.update(
      { id: order.id },
      {
        status: OrderStatus.Shipped,
        trackingNumber: shipment.trackingNumber,
        shippedAt: new Date(),
      }
    );
  });
 
  return {
    title: "Order Shipped",
    data: {
      trackingNumber: shipment.trackingNumber,
    },
  };
});

Stock adjustment with approval

export default AdjustStock({
  stages: [
    { name: "request", title: "Request" },
    { name: "approve", title: "Approval" },
    { name: "complete", title: "Complete" },
  ],
}, async (ctx, inputs) => {
  // Request stage
  const { itemId, adjustment, reason } = await ctx.ui.page("request", {
    stage: "request",
    title: "Stock Adjustment",
    content: [
      ctx.ui.inputs.text("itemId", { label: "Stock item ID" }),
      ctx.ui.inputs.number("adjustment", {
        label: "Adjustment (+/-)",
        placeholder: "Enter positive or negative number",
      }),
      ctx.ui.inputs.text("reason", { label: "Reason for adjustment" }),
    ],
  });
 
  const item = await models.stockItem.findOne({ id: itemId });
  if (!item) {
    throw new Error(`Stock item ${itemId} not found`);
  }
 
  const newQuantity = item.quantity + adjustment;
 
  // Approval required for large adjustments or negative stock
  const needsApproval = Math.abs(adjustment) > 100 || newQuantity < 0;
 
  if (needsApproval) {
    const approval = await ctx.ui.page("approve", {
      stage: "approve",
      title: "Approval Required",
      content: [
        ctx.ui.display.banner({
          title: newQuantity < 0 ? "Warning: Negative Stock" : "Large Adjustment",
          description: newQuantity < 0
            ? "This adjustment will result in negative stock levels."
            : "This adjustment exceeds the threshold requiring approval.",
          mode: "warning",
        }),
        ctx.ui.display.keyValue({
          data: [
            { label: "Item", value: item.name },
            { label: "Current Quantity", value: item.quantity.toString() },
            { label: "Adjustment", value: adjustment.toString() },
            { label: "New Quantity", value: newQuantity.toString() },
            { label: "Reason", value: reason },
            { label: "Requested by", value: ctx.identity?.email || "Unknown" },
          ],
        }),
      ],
      actions: [
        { label: "Approve", value: "approve", mode: "primary" },
        { label: "Reject", value: "reject", mode: "destructive" },
      ],
    });
 
    if (approval.action === "reject") {
      throw new Error("Adjustment rejected");
    }
  }
 
  // Apply the adjustment
  await ctx.step("apply adjustment", async () => {
    await models.stockItem.update(
      { id: itemId },
      { quantity: newQuantity }
    );
 
    await models.stockAdjustment.create({
      stockItemId: itemId,
      previousQuantity: item.quantity,
      newQuantity,
      adjustment,
      reason,
      approvedBy: needsApproval ? ctx.identity?.id : null,
    });
  });
 
  return {
    title: "Adjustment Complete",
    data: {
      item: item.name,
      previousQuantity: item.quantity,
      newQuantity,
    },
  };
});