Agent Canvas

Signature workflows

Agent execution timeline: the plan becomes a live run with tool calls, a human approval gate, error with retry, and a final artifact reveal.

Goal

Refund order #8412 and notify the customer

Plan ready
  1. Locate the order and payment

  2. Issue the refund

  3. Notify the customer

Install

npx shadcn@latest add https://labs.duku.design/r/agent-canvas.json

Ask your agent to use this

Connect DUKU Labs to MCP, then hand your coding agent this prompt:

Use the DUKU Labs MCP server to install the Agent Canvas component (get_component "agent-canvas", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.

Interaction

TryWhat happens
Run the planSteps activate in order; tool calls stream in with spinner → check per call
Reach the approval gateRun pauses on an amber pulsing step; Approve resumes, Reject stops with a resume option
Hit the failing toolStep turns error-red with the message; Retry re-runs from the failed call and succeeds
Complete the runThe artifact blurs in under the timeline; footer shows tokens and cost

Props

PropTypeDefaultDescription
goal / stepsstring / AgentStep[]The plan. Steps carry toolCalls, optional approval gate, optional failsOnceWith.
autoStartbooleanfalseRun on mount instead of showing the plan.
artifactReactNodeRendered when the run completes.
onStatusChange(status: AgentRunStatus) => voididle/running/waiting-approval/error/complete/rejected.
Source — registry/default/ai/agent-canvas.tsx
"use client";

import * as React from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";
import { Button } from "@/registry/default/ui/button";
import { Spinner } from "@/registry/default/ui/spinner";

/* ------------------------------------------------------------------ */
/* Model                                                                */
/* ------------------------------------------------------------------ */

export interface AgentToolCall {
  tool: string;
  args: string;
  /** Simulated duration in ms. */
  duration?: number;
  result?: string;
  /** First run fails with this error; a retry succeeds. */
  failsOnceWith?: string;
}

export interface AgentStep {
  id: string;
  title: string;
  toolCalls?: AgentToolCall[];
  /** Pause and require human approval before running. */
  approval?: { summary: string };
}

export type AgentRunStatus =
  | "idle"
  | "running"
  | "waiting-approval"
  | "error"
  | "complete"
  | "rejected";

export interface AgentCanvasProps
  extends React.HTMLAttributes<HTMLDivElement> {
  goal: string;
  steps: AgentStep[];
  /** Begin executing on mount. Default false — shows the plan first. */
  autoStart?: boolean;
  /** Rendered when the run completes. */
  artifact?: React.ReactNode;
  onStatusChange?: (status: AgentRunStatus) => void;
  ref?: React.Ref<HTMLDivElement>;
}

type StepState = "pending" | "active" | "done" | "error" | "waiting";
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

const STATUS_META: Record<AgentRunStatus, { label: string; tone: string; pulse?: boolean }> = {
  idle: { label: "Plan ready", tone: "text-muted-foreground" },
  running: { label: "Running", tone: "text-agent-active", pulse: true },
  "waiting-approval": { label: "Waiting for approval", tone: "text-agent-waiting", pulse: true },
  error: { label: "Error — retry available", tone: "text-agent-error" },
  complete: { label: "Complete", tone: "text-success" },
  rejected: { label: "Stopped by user", tone: "text-muted-foreground" },
};

/* ------------------------------------------------------------------ */
/* Bits                                                                 */
/* ------------------------------------------------------------------ */

function StepDot({ state }: { state: StepState }) {
  return (
    <span
      className={cn(
        "relative mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors duration-300",
        state === "done" && "border-success bg-success text-white",
        state === "active" && "border-agent-active",
        state === "waiting" && "border-agent-waiting",
        state === "error" && "border-agent-error bg-agent-error text-white",
        state === "pending" && "border-border"
      )}
    >
      {state === "done" ? (
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" className="size-3" aria-hidden="true">
          <path d="M5 13l4 4L19 7" />
        </svg>
      ) : null}
      {state === "error" ? (
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" className="size-3" aria-hidden="true">
          <path d="M18 6 6 18M6 6l12 12" />
        </svg>
      ) : null}
      {state === "active" || state === "waiting" ? (
        <>
          <span
            className={cn(
              "absolute inline-flex size-2.5 animate-ping rounded-full opacity-60",
              state === "active" ? "bg-agent-active" : "bg-agent-waiting"
            )}
          />
          <span
            className={cn(
              "size-2 rounded-full",
              state === "active" ? "bg-agent-active" : "bg-agent-waiting"
            )}
          />
        </>
      ) : null}
    </span>
  );
}

function ToolCallRow({
  call,
  state,
  error,
}: {
  call: AgentToolCall;
  state: "pending" | "running" | "done" | "error";
  error?: string;
}) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  useGSAP(
    () => {
      if (!ref.current) return;
      if (reduced) {
        gsap.set(ref.current, { opacity: 1, y: 0 });
        return;
      }
      gsap.fromTo(
        ref.current,
        { opacity: 0, y: 8, filter: "blur(4px)" },
        { opacity: 1, y: 0, filter: "blur(0px)", duration: 0.3, ease: "power2.out", clearProps: "filter" }
      );
    },
    { scope: ref }
  );

  return (
    <div
      ref={ref}
      className="flex items-start gap-2 rounded-lg border border-border/70 bg-muted/40 px-2.5 py-1.5 font-mono text-[11px] leading-5"
    >
      <span className="mt-1">
        {state === "running" ? (
          <Spinner size="sm" />
        ) : state === "done" ? (
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" className="size-3 text-success" aria-hidden="true">
            <path d="M5 13l4 4L19 7" />
          </svg>
        ) : state === "error" ? (
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" className="size-3 text-agent-error" aria-hidden="true">
            <path d="M18 6 6 18M6 6l12 12" />
          </svg>
        ) : (
          <span className="block size-3 rounded-full border border-border" />
        )}
      </span>
      <div className="min-w-0 flex-1">
        <p className="truncate">
          <span className="font-semibold text-foreground">{call.tool}</span>
          <span className="text-muted-foreground">({call.args})</span>
        </p>
        {state === "done" && call.result ? (
          <p className="truncate text-muted-foreground">→ {call.result}</p>
        ) : null}
        {state === "error" && error ? (
          <p className="truncate text-agent-error">✕ {error}</p>
        ) : null}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Canvas                                                               */
/* ------------------------------------------------------------------ */

export function AgentCanvas({
  goal,
  steps,
  autoStart = false,
  artifact,
  onStatusChange,
  className,
  ref,
  ...rest
}: AgentCanvasProps) {
  const reduced = useReducedMotion();
  const [status, setStatusRaw] = React.useState<AgentRunStatus>("idle");
  const [stepIdx, setStepIdx] = React.useState(-1);
  const [callIdx, setCallIdx] = React.useState(-1);
  const [failedOnce, setFailedOnce] = React.useState<Record<string, boolean>>({});
  const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
  const runToken = React.useRef(0);

  const setStatus = (s: AgentRunStatus) => {
    setStatusRaw(s);
    onStatusChange?.(s);
  };

  const stepState = (i: number): StepState => {
    if (status === "complete" || i < stepIdx) return "done";
    if (i > stepIdx) return "pending";
    if (status === "waiting-approval") return "waiting";
    if (status === "error") return "error";
    if (status === "running") return "active";
    return "pending";
  };

  const runFrom = React.useCallback(
    async (startStep: number, startCall: number) => {
      const token = ++runToken.current;
      setStatus("running");
      setErrorMsg(null);
      for (let i = startStep; i < steps.length; i++) {
        if (runToken.current !== token) return;
        const step = steps[i];
        setStepIdx(i);
        if (step.approval && !(startStep === i && startCall > -1)) {
          setCallIdx(-1);
          setStatus("waiting-approval");
          return; // resumes via approve()
        }
        const calls = step.toolCalls ?? [];
        for (let c = i === startStep ? Math.max(0, startCall) : 0; c < calls.length; c++) {
          if (runToken.current !== token) return;
          setCallIdx(c);
          await wait(calls[c].duration ?? 900);
          if (runToken.current !== token) return;
          const key = `${step.id}:${c}`;
          if (calls[c].failsOnceWith && !failedOnce[key]) {
            setFailedOnce((f) => ({ ...f, [key]: true }));
            setErrorMsg(calls[c].failsOnceWith ?? "Tool failed");
            setStatus("error");
            return;
          }
        }
        setCallIdx(calls.length);
      }
      setStepIdx(steps.length);
      setStatus("complete");
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [steps, failedOnce]
  );

  React.useEffect(() => {
    if (autoStart) runFrom(0, -1);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [autoStart]);

  const approve = () => {
    // continue current step's tool calls after the gate
    runFrom(stepIdx, 0);
  };
  const reject = () => {
    runToken.current++;
    setStatus("rejected");
  };
  const retry = () => runFrom(stepIdx, callIdx);

  const meta = STATUS_META[status];

  return (
    <div
      ref={ref}
      data-slot="agent-canvas"
      className={cn(
        "w-full max-w-lg rounded-2xl border border-border bg-card",
        className
      )}
      {...rest}
    >
      {/* Header */}
      <div className="flex items-start justify-between gap-3 border-b border-border px-4 py-3">
        <div className="min-w-0">
          <p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
            Goal
          </p>
          <p className="truncate text-sm font-medium text-foreground">{goal}</p>
        </div>
        <span className={cn("flex shrink-0 items-center gap-1.5 text-xs font-medium", meta.tone)}>
          {meta.pulse ? (
            <span className="relative flex size-2">
              <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
              <span className="relative inline-flex size-2 rounded-full bg-current" />
            </span>
          ) : null}
          {meta.label}
        </span>
      </div>

      {/* Timeline */}
      <ol className="flex flex-col px-4 py-3">
        {steps.map((step, i) => {
          const state = stepState(i);
          const calls = step.toolCalls ?? [];
          return (
            <li key={step.id} className="relative flex gap-3 pb-4 last:pb-0">
              {i < steps.length - 1 ? (
                <span
                  aria-hidden="true"
                  className={cn(
                    "absolute left-[9px] top-6 h-[calc(100%-1.25rem)] w-0.5 rounded transition-colors duration-500",
                    state === "done" ? "bg-success/50" : "bg-border"
                  )}
                />
              ) : null}
              <StepDot state={state} />
              <div className="min-w-0 flex-1">
                <p
                  className={cn(
                    "text-sm",
                    state === "pending" ? "text-muted-foreground" : "font-medium text-foreground"
                  )}
                >
                  {step.title}
                </p>

                {/* Tool calls appear as the step executes */}
                {(state === "active" || state === "error") && calls.length > 0 ? (
                  <div className="mt-1.5 flex flex-col gap-1">
                    {calls.slice(0, Math.max(0, callIdx + 1)).map((call, c) => (
                      <ToolCallRow
                        key={c}
                        call={call}
                        state={
                          status === "error" && c === callIdx
                            ? "error"
                            : c < callIdx
                              ? "done"
                              : "running"
                        }
                        error={errorMsg ?? undefined}
                      />
                    ))}
                  </div>
                ) : null}
                {state === "done" && calls.length > 0 ? (
                  <p className="mt-0.5 text-[11px] text-muted-foreground">
                    {calls.length} tool call{calls.length > 1 ? "s" : ""} ·{" "}
                    {calls.map((c) => c.tool).join(", ")}
                  </p>
                ) : null}

                {/* Approval gate */}
                {state === "waiting" && step.approval ? (
                  <div className="mt-2 rounded-lg border border-agent-waiting/40 bg-agent-waiting/10 p-2.5">
                    <p className="text-xs text-foreground">{step.approval.summary}</p>
                    <div className="mt-2 flex gap-2">
                      <Button size="sm" onClick={approve}>
                        Approve
                      </Button>
                      <Button size="sm" variant="outline" onClick={reject}>
                        Reject
                      </Button>
                    </div>
                  </div>
                ) : null}

                {/* Retry */}
                {state === "error" ? (
                  <div className="mt-2">
                    <Button size="sm" variant="outline" onClick={retry}>
                      Retry step
                    </Button>
                  </div>
                ) : null}
              </div>
            </li>
          );
        })}
      </ol>

      {/* Footer: start / artifact */}
      <div className="border-t border-border px-4 py-3">
        {status === "idle" ? (
          <Button className="w-full" onClick={() => runFrom(0, -1)}>
            Run plan
          </Button>
        ) : null}
        {status === "rejected" ? (
          <Button className="w-full" variant="outline" onClick={() => runFrom(stepIdx, -1)}>
            Resume run
          </Button>
        ) : null}
        {status === "complete" ? (
          artifact ? (
            <ArtifactReveal reduced={reduced}>{artifact}</ArtifactReveal>
          ) : (
            <p className="text-center text-xs text-success">Run complete</p>
          )
        ) : null}
        {status === "running" || status === "waiting-approval" || status === "error" ? (
          <p className="text-center text-[11px] tabular-nums text-muted-foreground">
            step {Math.min(stepIdx + 1, steps.length)}/{steps.length} · 12.4k tokens · $0.08
          </p>
        ) : null}
      </div>
    </div>
  );
}

function ArtifactReveal({
  children,
  reduced,
}: {
  children: React.ReactNode;
  reduced: boolean;
}) {
  const ref = React.useRef<HTMLDivElement>(null);
  useGSAP(
    () => {
      if (!ref.current) return;
      if (reduced) {
        gsap.set(ref.current, { opacity: 1, y: 0 });
        return;
      }
      gsap.fromTo(
        ref.current,
        { opacity: 0, y: 12, filter: "blur(8px)" },
        { opacity: 1, y: 0, filter: "blur(0px)", duration: 0.45, ease: "power3.out", clearProps: "filter" }
      );
    },
    { scope: ref }
  );
  return (
    <div ref={ref}>
      <p className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
        Artifact
      </p>
      {children}
    </div>
  );
}