Showcase

Support bot

Chat with escalation, memory, and confirmation gates.

supportmemorytools
Live preview
Source
'use client'import { useCallback, useMemo, useRef, useState } from 'react'import { createLocalStorageMemory } from '@agentskit/core'import type { AdapterFactory, StreamChunk, ToolDefinition } from '@agentskit/core'import { useChat, ChatContainer, InputBar } from '@agentskit/react'import '@/styles/agentskit-theme.css'import { initialAssistant } from './_shared/mock-adapter'import { ToolBadge } from './_shared/tool-badge'import { MdRenderer } from './_shared/md-renderer'/** * Branching reservation flow driven by a tiny state machine. Each step emits * a scripted assistant reply through a mock adapter, plus quick-reply buttons * for the next branch. Picking "Talk to a human" at any step escalates. */type Step =  | 'greet'  | 'party'  | 'date'  | 'time'  | 'confirm'  | 'done'  | 'order-which'  | 'order-status'  | 'order-followup'  | 'escalated'type OrderResult = {  orderId: string  status: string  carrier: string  tracking?: string  reason?: string  eta: string}type Option = {  label: string  next: Step  tool?: { name: string; args: Record<string, unknown>; result: unknown; durationMs: number }  widget?: { kind: 'order'; data: OrderResult }}const SCRIPT: Record<Step, { say: string; options: Option[] }> = {  greet: {    say: 'Hi — I can take a reservation, look up an order, or hand you to a human. What do you need?',    options: [      { label: 'Book a table', next: 'party' },      { label: 'Track an order', next: 'order-which' },      { label: 'Talk to a human', next: 'escalated' },    ],  },  party: {    say: 'How many people?',    options: [      { label: '2 people', next: 'date' },      { label: '4 people', next: 'date' },      { label: '6+ people', next: 'escalated' },      { label: 'Talk to a human', next: 'escalated' },    ],  },  date: {    say: 'Which day works?',    options: [      { label: 'Tonight', next: 'time' },      { label: 'Tomorrow', next: 'time' },      { label: 'This weekend', next: 'time' },      { label: 'Talk to a human', next: 'escalated' },    ],  },  time: {    say: 'Pick a time slot — I will check availability.',    options: [      {        label: '7:00 PM',        next: 'confirm',        tool: {          name: 'check_availability',          args: { time: '19:00' },          result: { available: true, table: 12 },          durationMs: 320,        },      },      {        label: '8:30 PM',        next: 'confirm',        tool: {          name: 'check_availability',          args: { time: '20:30' },          result: { available: true, table: 7 },          durationMs: 300,        },      },      { label: 'Talk to a human', next: 'escalated' },    ],  },  confirm: {    say: 'Got a table — confirm the booking?',    options: [      {        label: 'Confirm',        next: 'done',        tool: {          name: 'create_reservation',          args: { confirmed: true },          result: { id: 'RSV-9842', ok: true },          durationMs: 410,        },      },      { label: 'Cancel', next: 'greet' },      { label: 'Talk to a human', next: 'escalated' },    ],  },  done: {    say: 'Booked! Your confirmation number is **RSV-9842**. Anything else?',    options: [{ label: 'Start over', next: 'greet' }],  },  escalated: {    say: 'Escalated to a human support agent — you will hear back within 15 minutes.',    options: [{ label: 'Start over', next: 'greet' }],  },  'order-which': {    say: 'Which order? Pick a recent one or enter an ID in the box.',    options: [      {        label: '#48291 · Aurora ANC',        next: 'order-status',        tool: {          name: 'lookup_order',          args: { orderId: '#48291' },          result: {            status: 'shipped',            carrier: 'UPS',            tracking: '1Z999AA10123456784',            eta: '2026-04-25',          },          durationMs: 380,        },        widget: {          kind: 'order',          data: {            orderId: '#48291',            status: 'shipped',            carrier: 'UPS',            tracking: '1Z999AA10123456784',            eta: '2026-04-25',          },        },      },      {        label: '#47013 · Nimbus Stand',        next: 'order-status',        tool: {          name: 'lookup_order',          args: { orderId: '#47013' },          result: { status: 'delayed', carrier: 'USPS', reason: 'weather', eta: '2026-04-27' },          durationMs: 360,        },        widget: {          kind: 'order',          data: {            orderId: '#47013',            status: 'delayed',            carrier: 'USPS',            reason: 'weather',            eta: '2026-04-27',          },        },      },      { label: 'Talk to a human', next: 'escalated' },    ],  },  'order-status': {    say: 'Found it — see the details above. Anything else?',    options: [      {        label: 'Resend tracking email',        next: 'order-followup',        tool: {          name: 'resend_tracking_email',          args: { orderId: '#48291' },          result: { ok: true, to: 'user@example.com' },          durationMs: 280,        },      },      {        label: 'Change delivery address',        next: 'escalated',      },      { label: 'Back to menu', next: 'greet' },      { label: 'Talk to a human', next: 'escalated' },    ],  },  'order-followup': {    say: 'Tracking email resent to **user@example.com** — should arrive in under a minute.',    options: [      { label: 'Back to menu', next: 'greet' },      { label: 'Talk to a human', next: 'escalated' },    ],  },}function createStepAdapter(  stepRef: { current: Step },  pendingToolRef: { current: Option['tool'] | undefined },): AdapterFactory {  return {    createSource: () => ({      stream: async function* (): AsyncIterableIterator<StreamChunk> {        const call = pendingToolRef.current        pendingToolRef.current = undefined        if (call) {          yield {            type: 'tool_call',            toolCall: {              id: `call-${Math.random().toString(36).slice(2, 8)}`,              name: call.name,              args: JSON.stringify(call.args),            },          }          // Runtime runs the registered tool stub and emits tool_result.          yield { type: 'done' }          return        }        const reply = SCRIPT[stepRef.current].say        for (const ch of reply) {          await new Promise((r) => setTimeout(r, 14))          yield { type: 'text', content: ch }        }        yield { type: 'done' }      },      abort() {},    }),    capabilities: { streaming: true, tools: true },  }}function buildTools(): ToolDefinition[] {  const defs: { name: string; result: unknown; durationMs: number }[] = []  for (const step of Object.values(SCRIPT)) {    for (const opt of step.options) {      if (opt.tool && !defs.some((d) => d.name === opt.tool!.name)) {        defs.push({ name: opt.tool.name, result: opt.tool.result, durationMs: opt.tool.durationMs })      }    }  }  return defs.map<ToolDefinition>((d) => ({    name: d.name,    description: `Mock ${d.name}`,    schema: {},    async execute() {      await new Promise((r) => setTimeout(r, d.durationMs))      return JSON.stringify(d.result)    },  }))}function OrderCard({ order }: { order: OrderResult }) {  const isShipped = order.status === 'shipped'  return (    <div className="rounded-lg border border-ak-border bg-ak-midnight/60 p-3 font-mono text-xs">      <div className="flex items-center justify-between">        <span className="font-semibold text-ak-foam">{order.orderId}</span>        <span          className={`rounded-full px-2 py-0.5 text-[10px] uppercase tracking-widest ${            isShipped              ? 'bg-ak-green/15 text-ak-green'              : 'bg-[#f0b429]/15 text-[#f0b429]'          }`}        >          {order.status}        </span>      </div>      <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-ak-graphite">        <dt>Carrier</dt>        <dd className="text-ak-foam">{order.carrier}</dd>        {order.tracking ? (          <>            <dt>Tracking</dt>            <dd className="break-all text-ak-foam">{order.tracking}</dd>          </>        ) : null}        {order.reason ? (          <>            <dt>Delay</dt>            <dd className="text-ak-foam">{order.reason}</dd>          </>        ) : null}        <dt>ETA</dt>        <dd className="text-ak-foam">{order.eta}</dd>      </dl>    </div>  )}export function SupportBot() {  const stepRef = useRef<Step>('greet')  const pendingToolRef = useRef<Option['tool']>(undefined)  const [step, setStep] = useState<Step>('greet')  const [order, setOrder] = useState<OrderResult | null>(null)  const adapter = useMemo(() => createStepAdapter(stepRef, pendingToolRef), [])  const tools = useMemo(() => buildTools(), [])  const memory = useMemo(() => createLocalStorageMemory('ak:example:support'), [])  const chat = useChat({    adapter,    memory,    tools,    // 2 lets the runtime re-prompt once after the tool result so the adapter    // streams the step's scripted text. Any further iterations are a no-op    // because the adapter emits `done` on its second call.    maxToolIterations: 2,    initialMessages: [initialAssistant(SCRIPT.greet.say)],  })  const pick = useCallback(    (opt: Option) => {      stepRef.current = opt.next      pendingToolRef.current = opt.tool      setStep(opt.next)      if (opt.widget?.kind === 'order') setOrder(opt.widget.data)      if (opt.next === 'greet') setOrder(null)      void chat.send(opt.label)    },    [chat],  )  const options = SCRIPT[step].options  const idle = chat.status !== 'streaming'  return (    <div      data-ak-example      className="flex h-[520px] flex-col overflow-hidden rounded-lg border border-ak-border bg-ak-surface"    >      <ChatContainer className="flex-1 space-y-2 p-4">        {chat.messages          .filter((m) => m.role !== 'tool')          .map((m) => {            const showOrderCard =              order &&              m.role === 'assistant' &&              m.toolCalls?.some((t) => t.name === 'lookup_order' && t.status !== 'pending')            return (              <div key={m.id} className="flex flex-col gap-1.5">                {m.toolCalls?.map((t) => (                  <ToolBadge key={t.id} call={t} />                ))}                {showOrderCard ? <OrderCard order={order} /> : null}                {m.content ? (                  <div data-ak-message data-ak-role={m.role} className="rounded-lg bg-ak-midnight/40 p-3">                    <MdRenderer content={m.content} />                  </div>                ) : null}              </div>            )          })}        {idle ? (          <div className="flex flex-wrap gap-2 pt-1">            {options.map((opt) => {              const escalate = opt.next === 'escalated' && opt.label.includes('human')              return (                <button                  key={opt.label}                  type="button"                  onClick={() => pick(opt)}                  className={`rounded-full border px-3 py-1 font-mono text-xs transition ${                    escalate                      ? 'border-ak-red/40 text-ak-red hover:bg-ak-red/10'                      : 'border-ak-border text-ak-foam hover:border-ak-foam hover:bg-ak-foam/10'                  }`}                >                  {opt.label}                </button>              )            })}          </div>        ) : null}      </ChatContainer>      <InputBar chat={chat} />    </div>  )}
import type { AdapterFactory, StreamChunk, ToolDefinition } from '@agentskit/core'export type ToolCallEmit = {  name: string  args?: Record<string, unknown>  result?: unknown  durationMs?: number}export type Turn = {  /** Text streamed as the assistant reply. */  text: string  /** Optional tool calls to emit before the text. The runtime executes each via   *  the matching tool stub exposed by `toolsFor(turns)`. */  toolCalls?: ToolCallEmit[]  /** Optional reasoning stream emitted before tool calls / text. */  reasoning?: string}export function createMockAdapter(turns: Turn[], cps = 80): AdapterFactory {  let idx = 0  return {    createSource: () => ({      stream: async function* (): AsyncIterableIterator<StreamChunk> {        const turn = turns[idx % turns.length]        idx += 1        if (turn.reasoning) {          for (const ch of turn.reasoning) {            await sleep(1000 / cps)            yield { type: 'reasoning', content: ch }          }        }        if (turn.toolCalls) {          for (const call of turn.toolCalls) {            yield {              type: 'tool_call',              toolCall: {                id: `call-${Math.random().toString(36).slice(2, 8)}`,                name: call.name,                args: JSON.stringify(call.args ?? {}),              },            }          }        }        for (const ch of turn.text) {          await sleep(1000 / cps)          yield { type: 'text', content: ch }        }        yield { type: 'done' }      },      abort() {},    }),    capabilities: { streaming: true, tools: true },  }}/** * Build a registry of tool stubs whose `execute()` resolves to the mocked * result declared for that tool name in `turns`. When the controller sees a * `tool_call` chunk from the mock adapter it looks up the name here, runs the * stub (with a simulated latency), and emits `tool_result`. */export function toolsFor(turns: Turn[]): ToolDefinition[] {  const byName = new Map<string, ToolCallEmit>()  for (const t of turns) {    for (const c of t.toolCalls ?? []) {      if (!byName.has(c.name)) byName.set(c.name, c)    }  }  return Array.from(byName.values()).map<ToolDefinition>((call) => ({    name: call.name,    description: `Mock ${call.name}`,    schema: {},    async execute() {      if (call.durationMs) await sleep(call.durationMs)      return JSON.stringify(call.result ?? { ok: true })    },  }))}function sleep(ms: number) {  return new Promise<void>((r) => setTimeout(r, ms))}export function initialAssistant(content: string) {  return {    id: 'init',    role: 'assistant' as const,    content,    status: 'complete' as const,    createdAt: new Date(),  }}
'use client'import type { ToolCall } from '@agentskit/core'/** * Compact tool-call badge that mirrors the home hero demo: * `✓ name({ args }) Nms` in a green pill when complete, blue with a spinner * while pending. Use inside <Message>{m.toolCalls?.map(...)}</Message>. */export function ToolBadge({ call }: { call: ToolCall }) {  const done = call.status === 'complete' || call.status === 'error'  const error = call.status === 'error'  const args = formatArgs(call.args)  return (    <div      data-ak-tool-badge      className={`inline-flex max-w-full items-start gap-2 rounded-md border px-2.5 py-1 font-mono text-xs ${        error          ? 'border-ak-red/30 bg-ak-red/5 text-ak-red'          : done          ? 'border-ak-green/30 bg-ak-green/5 text-ak-green'          : 'border-ak-blue/30 bg-ak-blue/5 text-ak-blue'      }`}    >      <span className="mt-0.5 shrink-0">        {done ? (error ? '✗' : '✓') : <Spinner />}      </span>      <span className="min-w-0 break-all">        {call.name}        {args ? `(${args})` : '()'}      </span>    </div>  )}function formatArgs(raw: Record<string, unknown> | string | undefined): string {  if (!raw) return ''  const parsed =    typeof raw === 'string'      ? (() => {          try {            return JSON.parse(raw)          } catch {            return raw          }        })()      : raw  if (!parsed || typeof parsed !== 'object') return String(parsed)  const entries = Object.entries(parsed)  if (entries.length === 0) return ''  return entries.map(([k, v]) => `${k}: ${stringify(v)}`).join(', ')}function stringify(value: unknown): string {  if (typeof value === 'string') return `"${value}"`  if (value === null) return 'null'  if (typeof value === 'object') return JSON.stringify(value)  return String(value)}function Spinner() {  return (    <span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-ak-blue border-t-transparent" />  )}
'use client'import ReactMarkdown from 'react-markdown'import remarkGfm from 'remark-gfm'import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock'/** * Markdown renderer used across showcase examples. * react-markdown + remark-gfm handles tables, task lists, strikethrough, etc. * Fenced code blocks are rendered by fumadocs' DynamicCodeBlock for shiki * syntax highlighting consistent with the rest of the docs. */export function MdRenderer({ content }: { content: string }) {  return (    <div data-ak-md className="ak-md space-y-2 text-sm text-ak-foam">      <ReactMarkdown        remarkPlugins={[remarkGfm]}        components={{          code({ className, children }) {            const text = String(children ?? '').replace(/\n$/, '')            const match = /language-(\w+)/.exec(className ?? '')            // react-markdown v9 dropped the `inline` flag. Treat anything            // without a `language-*` class (and no newline) as inline.            if (!match && !text.includes('\n')) {              return (                <code className="rounded bg-ak-midnight px-1 py-0.5 font-mono text-[0.85em] text-ak-blue">                  {text}                </code>              )            }            return <DynamicCodeBlock lang={match?.[1] ?? 'text'} code={text} />          },          pre({ children }) {            // Block <code> is rendered directly by the `code` component above.            // Returning the children raw avoids nesting <div> (DynamicCodeBlock)            // inside <pre> or the surrounding <p>.            return <>{children}</>          },          a({ href, children }) {            return (              <a href={href} target="_blank" rel="noreferrer" className="text-ak-blue underline">                {children}              </a>            )          },          table({ children }) {            return (              <div className="my-2 overflow-x-auto">                <table className="w-full border-collapse text-left text-xs">{children}</table>              </div>            )          },          thead({ children }) {            return <thead className="bg-ak-midnight/60 text-ak-foam">{children}</thead>          },          th({ children }) {            return <th className="border-b border-ak-border px-3 py-2 font-semibold">{children}</th>          },          td({ children }) {            return <td className="border-b border-ak-border px-3 py-2 align-top">{children}</td>          },          blockquote({ children }) {            return (              <blockquote className="border-l-2 border-ak-border pl-3 text-ak-graphite">                {children}              </blockquote>            )          },          ul({ children }) {            return <ul className="my-1 list-disc space-y-1 pl-5">{children}</ul>          },          ol({ children }) {            return <ol className="my-1 list-decimal space-y-1 pl-5">{children}</ol>          },          h1({ children }) {            return <h2 className="mt-2 font-display text-lg font-semibold text-ak-foam">{children}</h2>          },          h2({ children }) {            return <h3 className="mt-2 font-display text-base font-semibold text-ak-foam">{children}</h3>          },          h3({ children }) {            return <h4 className="mt-1 font-display text-sm font-semibold text-ak-foam">{children}</h4>          },          p({ children }) {            // Render every paragraph as a div to avoid invalid nesting when            // fenced code blocks (rendered as <figure>/<div> via DynamicCodeBlock)            // land inside a <p> because of react-markdown's default wrapping.            return <div className="leading-relaxed">{children}</div>          },        }}      >        {content}      </ReactMarkdown>    </div>  )}
More examples
See all →