Completion

Flow completion

When a flow finishes, you control what the user sees. Use ctx.complete() to display a completion screen with a summary, automatically close the flow, allow the user to restart it, or send them to a Console tool.

Basic completion

The simplest completion just needs a title:

return ctx.complete({
  title: "Order processed",
});

Add a description for more context:

return ctx.complete({
  title: "Order processed",
  description: "The order has been submitted and the customer will receive a confirmation email.",
});

Completion with content

Display a summary of what happened using any of the display elements:

return ctx.complete({
  title: "Refund complete",
  description: "The refund has been processed successfully.",
  content: [
    ctx.ui.display.keyValue({
      data: [
        { key: "Order", value: order.reference },
        { key: "Refund amount", value: `£${refundAmount}` },
        { key: "Refund ID", value: refund.id },
      ],
    }),
  ],
});

You can use any display element in the content array:

return ctx.complete({
  title: "Stock count complete",
  content: [
    ctx.ui.display.banner({
      title: "3 discrepancies found",
      description: "Review the items below that don't match expected quantities.",
      mode: "warning",
    }),
    ctx.ui.display.table({
      data: discrepancies.map(d => ({
        SKU: d.sku,
        Expected: d.expected,
        Counted: d.counted,
        Variance: d.counted - d.expected,
      })),
    }),
  ],
});

Returning data

Use the data property to return structured data from the flow. This is useful when the flow is triggered programmatically and you need to pass results back:

return ctx.complete({
  title: "Order created",
  data: {
    orderId: order.id,
    orderReference: order.reference,
    totalAmount: order.total,
  },
});

The data is available when querying the flow's status via the API.

Auto-close

For flows that should complete without showing a completion screen, use autoClose. The flow closes immediately and shows a brief notification with your title and description:

return ctx.complete({
  title: "Item scanned",
  description: "Added to inventory",
  autoClose: true,
});

When autoClose is true, you cannot include content since there's no completion screen to display it on.

This is useful for:

  • Quick operational tasks that don't need a summary
  • Flows that are part of a larger workflow
  • Repetitive tasks where the user will immediately start another flow

Allow restart

Let users restart the flow from the completion screen. This is useful for repetitive tasks like processing multiple orders or scanning items.

Manual restart (default)

Show a button that lets the user restart:

return ctx.complete({
  title: "Order processed",
  allowRestart: {
    mode: "manual",
    buttonLabel: "Process another order",
  },
});

If you don't specify mode, it defaults to manual.

Automatic restart

Automatically restart the flow after completion. The user sees a brief notification instead of a completion screen:

return ctx.complete({
  title: "Item received",
  allowRestart: {
    mode: "auto",
  },
});

Restart with inputs

If your flow has inputs defined in the schema, you can pre-fill them for the restart:

flow ProcessOrders {
  inputs {
    warehouseId ID
  }
  @permission(roles: [Operator])
}
return ctx.complete({
  title: "Order processed",
  allowRestart: {
    mode: "manual",
    buttonLabel: "Next order",
    inputs: {
      warehouseId: inputs.warehouseId,  // Keep the same warehouse
    },
  },
});

If all inputs are optional, the inputs property is optional. If any inputs are required, you must provide them:

// Flow with required input - must provide inputs
return ctx.complete({
  allowRestart: {
    inputs: {
      orderId: nextOrderId,  // Required
    },
  },
});
 
// Flow with only optional inputs - inputs are optional
return ctx.complete({
  allowRestart: true,  // Can use boolean shorthand
});
 
// Flow with no inputs - can use boolean
return ctx.complete({
  allowRestart: true,
});

Redirect to a tool

Send the user to a Console tool when the flow completes. Use redirect with a reference from the generated tools object:

import { CreateOrder, models, tools } from "@teamkeel/sdk";
 
export default CreateOrder({}, async (ctx, inputs) => {
  const order = await ctx.step("create order", async () => {
    return models.order.create({ customerId: inputs.customerId });
  });
 
  return ctx.complete({
    title: "Order created",
    description: `Order ${order.reference} is ready to fulfil.`,
    redirect: {
      tool: tools.getOrder({ id: order.id }),
      label: "View order",
    },
  });
});

The completion screen shows the redirect as its primary button. Clicking it opens the getOrder tool for that order.

Pass a tool reference on its own to use the tool's name as the button label:

return ctx.complete({
  title: "Orders imported",
  redirect: tools.listOrders(),
});

To set a label, use the { tool, label } form. Spreading a label onto a reference ({ ...tools.listOrders(), label: "View orders" }) is a type error.

Auto-redirect

Set autoRedirect to skip the completion screen and open the tool straight away:

return ctx.complete({
  title: "Order created",
  redirect: {
    tool: tools.getOrder({ id: order.id }),
    autoRedirect: true,
  },
});

The title and description appear as a notification. content and the restart button are not shown.

Tool references

@teamkeel/sdk exports a tools object with one accessor for each Console tool in your project. Each accessor takes the tool's inputs and returns a reference to that tool. See the Tools API for the full description.

  • Accessors are named after the tool id in camelCase. A tool's default id is its action or flow name in kebab-case, so the getOrder action has the tool id get-order and the accessor tools.getOrder.
  • Tools you duplicate or give a custom id in the tools configuration get their own accessors.
  • Inputs are typed from the target. An action tool takes the action's inputs and a flow tool takes the flow's inputs. When every input is optional, or the tool has no inputs (hubs and ledgers), call the accessor with no arguments.

These mistakes fail type-checking, both locally and in the platform build, so they never reach a deploy:

  • A hand-written reference such as { toolId: "get-order" }. References only come from the tools accessors.
  • An accessor for a tool that doesn't exist.
  • Inputs with the wrong shape, or missing required inputs.

After you add, duplicate or rename a Console tool, run keel generate so that tools picks up the change.

What the user sees

  • Action tools open with the inputs pre-filled.
  • Flow tools open the target flow in a dialog with the inputs pre-filled.
  • Hubs open the hub. Ledgers open their hub.
  • If the target tool no longer exists, or the user can't access it, the button is hidden. With autoRedirect, the Console shows the normal completion screen instead.

Redirects only apply to runs started from the Console. A run started through external access ignores them, and so does a child flow started with ctx.flows.run (see Composing flows).

redirect cannot be combined with autoClose. This is a type error.

redirect cannot be combined with allowRestart: { mode: "auto" }. The type-checker does not catch this, so the flow throws when it completes.

redirect can be combined with a manual allowRestart. The completion screen then shows both buttons, unless autoRedirect is set.

Stages

If your flow uses stages, you can assign the completion to a stage:

export default ProcessReturn({
  stages: [
    { key: "verify", name: "Verify" },
    { key: "process", name: "Process" },
    { key: "complete", name: "Complete" },
  ],
}, async (ctx, inputs) => {
  // ... flow steps ...
 
  return ctx.complete({
    stage: "complete",
    title: "Return processed",
    content: [
      // ...
    ],
  });
});

Full-width layout

For completion screens with wide content like tables, use fullWidth:

return ctx.complete({
  title: "Inventory audit complete",
  fullWidth: true,
  content: [
    ctx.ui.display.table({
      data: auditResults,
    }),
  ],
});

Complete example

Here's a complete flow showing different completion patterns:

import { ProcessRefund, models, tools } from "@teamkeel/sdk";
 
export default ProcessRefund({
  stages: [
    { key: "select", name: "Select Order" },
    { key: "confirm", name: "Confirm" },
    { key: "complete", name: "Complete" },
  ],
}, async (ctx, inputs) => {
  // Select order stage
  const { orderId } = await ctx.ui.page("select", {
    stage: "select",
    title: "Select Order to Refund",
    content: [
      ctx.ui.select.table("orderId", {
        label: "Order",
        data: await getRefundableOrders(),
      }),
    ],
  });
 
  const order = await models.order.findOne({ id: orderId });
 
  // Confirm stage
  const { action } = await ctx.ui.page("confirm", {
    stage: "confirm",
    title: "Confirm Refund",
    content: [
      ctx.ui.display.keyValue({
        data: [
          { key: "Order", value: order.reference },
          { key: "Customer", value: order.customerName },
          { key: "Amount", value: `£${order.total}` },
        ],
      }),
    ],
    actions: [
      { label: "Process Refund", value: "refund", mode: "primary" },
      { label: "Cancel", value: "cancel" },
    ],
  });
 
  if (action === "cancel") {
    return ctx.complete({
      title: "Refund cancelled",
      autoClose: true,
    });
  }
 
  // Process the refund
  const refund = await ctx.step("process refund", async () => {
    const stripe = require("stripe")(ctx.secrets.STRIPE_KEY);
    return await stripe.refunds.create(
      { payment_intent: order.paymentIntentId },
      { idempotencyKey: `refund-${order.id}` }
    );
  });
 
  await ctx.step("update order", async () => {
    await models.order.update(
      { id: order.id },
      { status: "Refunded", refundId: refund.id }
    );
  });
 
  // Completion with summary, a link to the order and a restart option
  return ctx.complete({
    stage: "complete",
    title: "Refund processed",
    description: "The customer will receive their refund within 5-10 business days.",
    content: [
      ctx.ui.display.banner({
        title: "Success",
        description: `Refund of £${order.total} has been initiated.`,
        mode: "success",
      }),
      ctx.ui.display.keyValue({
        data: [
          { key: "Order", value: order.reference },
          { key: "Refund ID", value: refund.id },
          { key: "Amount", value: `£${order.total}` },
        ],
      }),
    ],
    data: {
      orderId: order.id,
      refundId: refund.id,
      amount: order.total,
    },
    redirect: {
      tool: tools.getOrder({ id: order.id }),
      label: "View order",
    },
    allowRestart: {
      mode: "manual",
      buttonLabel: "Process another refund",
    },
  });
});

Options reference

OptionTypeDescription
titlestringHeading displayed on the completion screen
descriptionstringSubheading with additional context
contentDisplayElement[]Array of display elements to show
dataanyStructured data returned from the flow
stagestringStage key to mark as complete
autoClosebooleanClose immediately without showing completion screen
fullWidthbooleanUse full-width layout for wide content
allowRestartboolean | objectEnable restart functionality
allowRestart.mode"manual" | "auto"Show button (manual) or restart automatically (auto)
allowRestart.buttonLabelstringCustom label for the restart button
allowRestart.inputsobjectInput values to use when restarting
redirectToolRef | objectConsole tool to open once the flow completes, shown as the primary button
redirect.toolToolRefThe tool to open, from a tools accessor, e.g. tools.getOrder({ id })
redirect.labelstringButton label. Defaults to the tool's name
redirect.autoRedirectbooleanOpen the tool immediately instead of showing the completion screen