JDS.

Search docs

Find a page or component

Recipes

Agent run

A full support-agent turn: reasoning, a plan, a tool call, approval before a refund, a streamed answer with sources.

Support agent

What should the agent do?

Install

pnpm dlx shadcn@latest add @jds/agent-run

Installs the recipe and every component it uses into your project, ready to run.

Source

components/agent-run/agent-run.tsx
"use client"

import * as React from "react"
import { AnimatePresence, motion } from "motion/react"
import { toast } from "sonner"

import { AgentSteps, type AgentStep } from "@/components/ai/agent-steps"
import { Approval, type ApprovalDecision } from "@/components/ai/approval"
import { Conversation, ConversationContent, ConversationScrollButton } from "@/components/ai/conversation"
import { Message, MessageAction, MessageActions, MessageContent } from "@/components/ai/message"
import {
  PromptInput,
  PromptInputAttachButton,
  PromptInputAttachments,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputToolbar,
  PromptInputTools,
  type ChatStatus,
} from "@/components/ai/prompt-input"
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai/reasoning"
import { Response } from "@/components/ai/response"
import { TypingIndicator } from "@/components/ai/shimmer"
import { Sources, type Source } from "@/components/ai/sources"
import { Suggestion, Suggestions } from "@/components/ai/suggestions"
import { ToolCall, ToolCallContent, ToolCallHeader, ToolCallSection, type ToolState } from "@/components/ai/tool-call"
import { CopyIcon, RegenerateIcon, ThumbsDownIcon, ThumbsUpIcon } from "@/lib/icons"

const PROMPT = "Refund order #4821 and let the customer know."

const REASONING =
  "The customer reported a damaged item. I should confirm the order exists, check it is inside the 30-day refund window, then issue the refund. Refunds move money, so I need approval before executing."

const RESPONSE = `Done. Order **#4821** was refunded in full.

| Item | Amount |
| --- | --- |
| Ceramic pour-over set | €64.00 |
| Shipping | €4.90 |
| **Total refunded** | **€68.90** |

I also sent Maria a confirmation email. Funds usually land in **3 to 5 business days**, per the payment provider's policy.

\`\`\`ts
await refunds.create({ orderId: "4821", amount: 6890, reason: "damaged" })
\`\`\`
`

const SOURCES: Source[] = [
  { url: "https://docs.stripe.com/refunds", title: "Refund and cancel payments" },
  { url: "https://example.com/policies/returns", title: "Returns and refunds policy" },
  { url: "https://example.com/orders/4821", title: "Order #4821" },
]

const STEP_LABELS = ["Look up order #4821", "Check refund eligibility", "Issue refund", "Email the customer"]

type Phase =
  | "idle"
  | "submitted"
  | "reasoning"
  | "steps"
  | "tool"
  | "approval"
  | "executing"
  | "responding"
  | "done"
  | "denied"

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))

export default function AgentRun() {
  const [phase, setPhase] = React.useState<Phase>("idle")
  const [userText, setUserText] = React.useState<string | null>(null)
  const [reasoning, setReasoning] = React.useState("")
  const [activeStep, setActiveStep] = React.useState(-1)
  const [toolState, setToolState] = React.useState<ToolState>("input-streaming")
  const [decision, setDecision] = React.useState<ApprovalDecision>("pending")
  const [response, setResponse] = React.useState("")
  const run = React.useRef(0)
  const approvalResolver = React.useRef<((ok: boolean) => void) | null>(null)

  const stream = async (text: string, set: (s: string) => void, id: number, speed = 14) => {
    const words = text.split(/(\s+)/)
    let out = ""
    for (const w of words) {
      if (run.current !== id) return false
      out += w
      set(out)
      await wait(speed + Math.random() * speed)
    }
    return true
  }

  const start = async (text: string) => {
    const id = ++run.current
    const alive = () => run.current === id
    setUserText(text)
    setReasoning("")
    setResponse("")
    setActiveStep(-1)
    setToolState("input-streaming")
    setDecision("pending")

    setPhase("submitted")
    await wait(700)
    if (!alive()) return
    setPhase("reasoning")
    if (!(await stream(REASONING, setReasoning, id, 22))) return
    await wait(300)
    if (!alive()) return

    setPhase("steps")
    setActiveStep(0)
    await wait(900)
    if (!alive()) return
    setActiveStep(1)
    setPhase("tool")
    await wait(500)
    setToolState("input-available")
    await wait(1100)
    if (!alive()) return
    setToolState("output-available")
    await wait(400)
    setActiveStep(2)

    setPhase("approval")
    const ok = await new Promise<boolean>((resolve) => (approvalResolver.current = resolve))
    if (!alive()) return
    setDecision(ok ? "approved" : "denied")
    if (!ok) {
      setPhase("denied")
      return
    }

    setPhase("executing")
    await wait(900)
    if (!alive()) return
    setActiveStep(3)
    await wait(700)
    if (!alive()) return
    setActiveStep(4)
    setPhase("responding")
    if (!(await stream(RESPONSE, setResponse, id, 10))) return
    setPhase("done")
  }

  const stop = () => {
    run.current++
    approvalResolver.current = null
    setPhase((p) => (p === "idle" ? p : "done"))
  }

  const status: ChatStatus =
    phase === "submitted" ? "submitted" : phase === "idle" || phase === "done" || phase === "denied" ? "ready" : "streaming"

  const steps: AgentStep[] = STEP_LABELS.map((label, i) => ({
    id: label,
    label,
    status:
      phase === "denied" && i === 2
        ? "error"
        : i < activeStep
          ? "complete"
          : i === activeStep
            ? "active"
            : "pending",
    detail: i === 1 && activeStep > 1 ? "Delivered 6 days ago, within the 30-day window" : undefined,
  }))

  const reached = (p: Phase) => {
    const order: Phase[] = ["idle", "submitted", "reasoning", "steps", "tool", "approval", "executing", "responding", "done"]
    if (phase === "denied") return order.indexOf(p) <= order.indexOf("approval")
    return order.indexOf(phase) >= order.indexOf(p)
  }

  return (
    <div className="flex h-160 w-full flex-col overflow-hidden rounded-2xl border bg-background shadow-2xl shadow-black/20">
      <div className="flex items-center gap-2 border-b px-4 py-3">
        <span className="size-2 rounded-full bg-foreground" />
        <span className="text-sm font-medium">Support agent</span>
      </div>

      <Conversation>
        <ConversationContent className="gap-5">
          {!userText && (
            <div className="flex flex-1 flex-col items-center justify-center gap-4 py-16 text-center">
              <p className="font-heading text-3xl">What should the agent do?</p>
              <Suggestions className="justify-center">
                <Suggestion suggestion={PROMPT} onSelect={start} />
              </Suggestions>
            </div>
          )}

          {userText && (
            <Message from="user">
              <MessageContent>{userText}</MessageContent>
            </Message>
          )}

          {userText && (
            <Message from="assistant">
              <MessageContent>
                {phase === "submitted" && <TypingIndicator />}

                {reached("reasoning") && (
                  <Reasoning isStreaming={phase === "reasoning"}>
                    <ReasoningTrigger />
                    <ReasoningContent>{reasoning}</ReasoningContent>
                  </Reasoning>
                )}

                {reached("steps") && <AgentSteps steps={steps} />}

                {reached("tool") && (
                  <ToolCall>
                    <ToolCallHeader name="orders.lookup" state={toolState} />
                    <ToolCallContent>
                      <ToolCallSection label="Input" value={{ orderId: "4821" }} />
                      {toolState === "output-available" && (
                        <ToolCallSection
                          label="Output"
                          value={{ status: "delivered", total: 68.9, currency: "EUR", deliveredAt: "2026-09-22" }}
                        />
                      )}
                    </ToolCallContent>
                  </ToolCall>
                )}

                {reached("approval") && (
                  <Approval
                    title="Refund €68.90 to Maria Rossi"
                    description="This moves money back to the original payment method and can't be undone."
                    decision={decision}
                    approveLabel="Refund"
                    onApprove={() => approvalResolver.current?.(true)}
                    onDeny={() => approvalResolver.current?.(false)}
                  />
                )}

                {phase === "denied" && (
                  <p className="text-muted-foreground">Understood, I won&apos;t refund the order. Anything else?</p>
                )}

                {reached("responding") && (
                  <>
                    <Response isAnimating={phase === "responding"}>{response}</Response>
                    <AnimatePresence>
                      {phase === "done" && (
                        <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col gap-3">
                          <Sources sources={SOURCES} />
                          <MessageActions>
                            <MessageAction label="Copy" onClick={() => toast("Copied to clipboard")}>
                              <CopyIcon />
                            </MessageAction>
                            <MessageAction label="Replay" onClick={() => start(PROMPT)}>
                              <RegenerateIcon />
                            </MessageAction>
                            <MessageAction label="Good response" onClick={() => toast("Thanks for the feedback")}>
                              <ThumbsUpIcon />
                            </MessageAction>
                            <MessageAction label="Bad response" onClick={() => toast("Thanks for the feedback")}>
                              <ThumbsDownIcon />
                            </MessageAction>
                          </MessageActions>
                        </motion.div>
                      )}
                    </AnimatePresence>
                  </>
                )}
              </MessageContent>
            </Message>
          )}
        </ConversationContent>
        <ConversationScrollButton />
      </Conversation>

      <div className="p-3 pt-0">
        <PromptInput status={status} onSubmit={({ text }) => start(text || PROMPT)}>
          <PromptInputAttachments />
          <PromptInputTextarea placeholder={`Try: ${PROMPT}`} />
          <PromptInputToolbar>
            <PromptInputTools>
              <PromptInputAttachButton />
            </PromptInputTools>
            <PromptInputSubmit onStop={stop} />
          </PromptInputToolbar>
        </PromptInput>
      </div>
    </div>
  )
}