OSAGI

Approval Steps

Component

About

An AI agent's run as a card: steps tick off on their own with tool, result and timing, and the run stops at a step a person must approve, with the action, its arguments and Approve or Reject. From the AI Assistant Kit → ai.osagi.finance/kits/ai-assistant

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Approval Steps to your project and fits it to your theme.

Code

How Approval Steps is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import {
  type AgentStep,
  type ApprovalDecision,
  ApprovalSteps,
} from "@/components/ui/approval-steps";

const settings = {
  amount: "$129.00",
  /** Off: the run starts at the step that asks instead of the first one. */
  autoplay: true,
  card: "Visa •••• 4242",
  /** How long each step of the agent takes, in milliseconds. */
  stepMs: 1100,
};

/** The agent's plan for a support ticket; the refund needs a person's go-ahead. */
const PLAN = [
  {
    id: "read",
    ms: 640,
    result: "“The mug arrived in pieces.” Two photos attached.",
    title: "Read the ticket",
    tool: "tickets.get",
  },
  {
    id: "order",
    ms: 910,
    result: "Order 4821 · delivered Oct 14 · paid by card",
    title: "Look up the order",
    tool: "orders.get",
  },
  {
    id: "policy",
    ms: 1180,
    result: "Damaged on arrival: full refund within 30 days.",
    title: "Check the refund policy",
    tool: "policy.search",
  },
  {
    asks: true,
    id: "refund",
    ms: 1420,
    result: "Refund issued, it lands in 5–10 days.",
    title: "Refund the payment",
    tool: "payments.refund",
  },
  {
    id: "reply",
    ms: 760,
    result: "Sent: “Your refund is on its way.”",
    title: "Reply to the customer",
    tool: "email.send",
  },
  {
    id: "close",
    ms: 300,
    result: "Ticket 1043 solved.",
    title: "Close the ticket",
    tool: "tickets.close",
  },
];

const GATE = PLAN.findIndex((step) => step.asks);

/**
 * Where the run is: the step in progress, and whether it runs, waits for you or was stopped.
 * `round` grows with every new run, so a decision sent in an earlier one changes nothing.
 */
const START = { cleared: -1, cursor: 0, note: "", phase: "run", round: 0 };
const PAUSED = { ...START, cursor: GATE, phase: "wait" };

const wait = (ms: number) =>
  new Promise<void>((resolve) => {
    setTimeout(resolve, ms);
  });

/** An agent that works through a ticket by itself and stops to ask before it refunds. */
export default function Demo(props: Partial<typeof settings>) {
  const { amount, autoplay, card, stepMs } = { ...settings, ...props };
  const [run, setRun] = useState(autoplay ? START : PAUSED);
  const [lastAutoplay, setLastAutoplay] = useState(autoplay);
  if (lastAutoplay !== autoplay) {
    setLastAutoplay(autoplay);
    setRun((current) => ({
      ...(autoplay ? START : PAUSED),
      round: current.round + 1,
    }));
  }
  const { cleared, cursor, note, phase } = run;
  const round = useRef(run.round);
  useEffect(() => {
    round.current = run.round;
  }, [run.round]);

  useEffect(() => {
    if (phase !== "run" || cursor >= PLAN.length) {
      return;
    }
    const asks = cursor === GATE && cleared !== GATE;
    const id = setTimeout(
      () =>
        setRun((current) =>
          asks
            ? { ...current, phase: "wait" }
            : { ...current, cursor: current.cursor + 1 }
        ),
      asks ? stepMs * 0.6 : stepMs
    );
    return () => clearTimeout(id);
  }, [cleared, cursor, phase, stepMs]);

  const decide = useCallback(async (decision: ApprovalDecision) => {
    const asked = round.current;
    await wait(450);
    setRun((current) => {
      // A new run started meanwhile, or this one has moved on: the decision is for nothing here.
      if (
        current.round !== asked ||
        current.phase !== "wait" ||
        current.cursor !== GATE
      ) {
        return current;
      }
      return decision.decision === "approve"
        ? { ...current, cleared: GATE, phase: "run" }
        : { ...current, note: decision.note, phase: "stopped" };
    });
  }, []);

  const replay = useCallback(
    () => setRun((current) => ({ ...START, round: current.round + 1 })),
    []
  );

  const steps = PLAN.map((step, index): AgentStep => {
    const base = { id: step.id, title: step.title, tool: step.tool };
    if (index < cursor) {
      return {
        ...base,
        durationMs: step.ms,
        result: step.result,
        status: "done",
      };
    }
    if (index > cursor) {
      return { ...base, status: phase === "stopped" ? "skipped" : "queued" };
    }
    if (phase === "stopped") {
      return { ...base, note, status: "rejected" };
    }
    if (phase === "wait") {
      return {
        ...base,
        approval: {
          action: `Refund ${amount} to ${card}`,
          params: [
            { label: "amount", value: amount },
            { label: "method", value: card },
            { label: "reason", value: "damaged_on_arrival" },
          ],
          risk: "medium",
        },
        status: "approval",
      };
    }
    return { ...base, status: "running" };
  });

  const isOver = phase === "stopped" || cursor >= PLAN.length;

  return (
    <div className="flex min-h-[640px] w-full flex-col items-center justify-center gap-4 bg-background p-6">
      <ApprovalSteps
        agent="Support agent"
        onDecide={decide}
        steps={steps}
        title="Refund a damaged order"
      />
      <div className="flex h-11 items-center">
        {isOver ? (
          <button
            className="h-11 rounded-lg px-4 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            onClick={replay}
            type="button"
          >
            Run it again
          </button>
        ) : null}
      </div>
    </div>
  );
}

Dependencies

More by OSAGI

View all
  • AI Orb

    AI Orb

    83
  • KPI Tile

    KPI Tile

    28
  • OTP Input

    OTP Input

    12
  • Reasoning Thread

    Reasoning Thread

    8
  • Assistant Composer

    Assistant Composer

    8
  • Release Notes

    Release Notes

  • Docs TOC

    Docs TOC

  • Docs Search

    Docs Search