branch:
CallableDemo.tsx
11594 bytesRaw
import { useAgent } from "agents/react";
import { useState } from "react";
import { Button, Input, Surface, Text } from "@cloudflare/kumo";
import { DemoWrapper } from "../../layout";
import {
  LogPanel,
  ConnectionStatus,
  CodeExplanation,
  HighlightedCode,
  type CodeSection
} from "../../components";
import { useLogs, useUserId, useToast } from "../../hooks";
import type { CallableAgent } from "./callable-agent";

const codeSections: CodeSection[] = [
  {
    title: "Expose methods with @callable",
    description:
      'Decorate any method with @callable() to make it available as an RPC endpoint. The decorator accepts an optional description that shows up in listMethods(). Requires "target": "ES2021" or later in your tsconfig.json. Do not enable "experimentalDecorators" — the SDK uses TC39 standard decorators, not TypeScript legacy decorators.',
    code: `import { Agent, callable } from "agents";

// tsconfig.json needs: "target": "ES2021" (or later)
// Do NOT set "experimentalDecorators": true

class CallableAgent extends Agent<Env> {
  @callable({ description: "Add two numbers" })
  add(a: number, b: number): number {
    return a + b;
  }

  @callable({ description: "Simulate an async operation" })
  async slowOperation(delayMs: number): Promise<string> {
    await new Promise(resolve => setTimeout(resolve, delayMs));
    return \`Completed after \${delayMs}ms\`;
  }
}`
  },
  {
    title: "Call methods from the client",
    description:
      "Use agent.call() to invoke any @callable method. Arguments are passed as an array. Errors thrown on the server are re-thrown on the client.",
    code: `const agent = useAgent({
  agent: "callable-agent",
  name: "my-instance",
});

// Positional arguments passed as an array
const sum = await agent.call("add", [5, 3]);

// Async methods work the same way
const msg = await agent.call("slowOperation", [1000]);

// Errors propagate to the client
try {
  await agent.call("throwError", ["oops"]);
} catch (e) {
  console.error(e.message); // "oops"
}`
  },
  {
    title: "Self-describing APIs",
    description:
      "Agents can introspect their own callable methods at runtime using getCallableMethods(). This makes it easy to build dynamic UIs or tooling on top of agents.",
    code: `  @callable()
  listMethods() {
    return Array.from(this.getCallableMethods().entries())
      .map(([name, meta]) => ({
        name,
        description: meta.description,
      }));
  }`
  }
];

export function CallableDemo() {
  const userId = useUserId();
  const { logs, addLog, clearLogs } = useLogs();
  const { toast } = useToast();
  const [methods, setMethods] = useState<
    Array<{ name: string; description?: string }>
  >([]);
  const [argA, setArgA] = useState("5");
  const [argB, setArgB] = useState("3");
  const [echoMessage, setEchoMessage] = useState("Hello, Agent!");
  const [delayMs, setDelayMs] = useState("1000");
  const [errorMessage, setErrorMessage] = useState("Test error");
  const [lastResult, setLastResult] = useState<string | null>(null);

  const agent = useAgent<CallableAgent, {}>({
    agent: "callable-agent",
    name: `callable-demo-${userId}`,
    onOpen: () => addLog("info", "connected"),
    onClose: () => addLog("info", "disconnected"),
    onError: () => addLog("error", "error", "Connection error")
  });

  const handleCall = async (method: string, args: unknown[]) => {
    addLog("out", "call", { method, args });
    setLastResult(null);
    try {
      const result = await (
        agent.call as (m: string, a?: unknown[]) => Promise<unknown>
      )(method, args);
      addLog("in", "result", result);
      setLastResult(JSON.stringify(result, null, 2));
      toast(method + "() → " + JSON.stringify(result).slice(0, 60), "success");
      return result;
    } catch (e) {
      const error = e instanceof Error ? e.message : String(e);
      addLog("error", "error", error);
      setLastResult(`Error: ${error}`);
      toast(error, "error");
      throw e;
    }
  };

  const handleListMethods = async () => {
    try {
      const result = (await handleCall("listMethods", [])) as Array<{
        name: string;
        description?: string;
      }>;
      setMethods(result);
    } catch {
      // Error already logged
    }
  };

  return (
    <DemoWrapper
      title="Callable Methods"
      description={
        <>
          Decorate any method with{" "}
          <code className="text-xs bg-kumo-fill px-1 py-0.5 rounded">
            @callable()
          </code>{" "}
          to expose it as an RPC endpoint. Clients call these methods using{" "}
          <code className="text-xs bg-kumo-fill px-1 py-0.5 rounded">
            agent.call()
          </code>{" "}
          over WebSocket — arguments are serialized, errors propagate, and async
          methods just work. Methods can optionally include a description for
          self-documenting APIs.
        </>
      }
      statusIndicator={
        <ConnectionStatus
          status={
            agent.readyState === WebSocket.OPEN ? "connected" : "connecting"
          }
        />
      }
    >
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        {/* Controls */}
        <div className="space-y-6">
          {/* Math Operations */}
          <Surface className="p-4 rounded-lg ring ring-kumo-line">
            <div className="mb-4">
              <Text variant="heading3">Math Operations</Text>
            </div>
            <div className="flex gap-2 mb-3">
              <Input
                aria-label="Argument A"
                type="number"
                value={argA}
                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                  setArgA(e.target.value)
                }
                className="w-20"
              />
              <Input
                aria-label="Argument B"
                type="number"
                value={argB}
                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                  setArgB(e.target.value)
                }
                className="w-20"
              />
            </div>
            <div className="flex gap-2">
              <Button
                variant="primary"
                onClick={() => handleCall("add", [Number(argA), Number(argB)])}
              >
                add({argA}, {argB})
              </Button>
              <Button
                variant="secondary"
                onClick={() =>
                  handleCall("multiply", [Number(argA), Number(argB)])
                }
              >
                multiply({argA}, {argB})
              </Button>
            </div>
          </Surface>

          {/* Echo */}
          <Surface className="p-4 rounded-lg ring ring-kumo-line">
            <div className="mb-4">
              <Text variant="heading3">Echo</Text>
            </div>
            <div className="flex gap-2">
              <Input
                aria-label="Echo message"
                type="text"
                value={echoMessage}
                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                  setEchoMessage(e.target.value)
                }
                className="flex-1"
              />
              <Button
                variant="primary"
                onClick={() => handleCall("echo", [echoMessage])}
              >
                Echo
              </Button>
            </div>
          </Surface>

          {/* Async Operation */}
          <Surface className="p-4 rounded-lg ring ring-kumo-line">
            <div className="mb-4">
              <Text variant="heading3">Async Operation</Text>
            </div>
            <div className="flex gap-2">
              <Input
                aria-label="Delay in milliseconds"
                type="number"
                value={delayMs}
                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                  setDelayMs(e.target.value)
                }
                className="w-24"
                placeholder="ms"
              />
              <Button
                variant="primary"
                onClick={() => handleCall("slowOperation", [Number(delayMs)])}
              >
                slowOperation({delayMs})
              </Button>
            </div>
            <p className="text-xs text-kumo-subtle mt-2">
              Simulates a slow operation with configurable delay
            </p>
          </Surface>

          {/* Error Handling */}
          <Surface className="p-4 rounded-lg ring ring-kumo-line">
            <div className="mb-4">
              <Text variant="heading3">Error Handling</Text>
            </div>
            <div className="flex gap-2">
              <Input
                aria-label="Error message"
                type="text"
                value={errorMessage}
                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                  setErrorMessage(e.target.value)
                }
                className="flex-1"
              />
              <Button
                variant="destructive"
                onClick={() =>
                  handleCall("throwError", [errorMessage]).catch(() => {})
                }
              >
                Throw Error
              </Button>
            </div>
          </Surface>

          {/* Utility */}
          <Surface className="p-4 rounded-lg ring ring-kumo-line">
            <div className="mb-4">
              <Text variant="heading3">Utility Methods</Text>
            </div>
            <div className="flex gap-2 flex-wrap">
              <Button
                variant="secondary"
                onClick={() => handleCall("getTimestamp", [])}
              >
                getTimestamp()
              </Button>
              <Button variant="secondary" onClick={handleListMethods}>
                listMethods()
              </Button>
            </div>
          </Surface>

          {/* Available Methods */}
          {methods.length > 0 && (
            <Surface className="p-4 rounded-lg ring ring-kumo-line">
              <div className="mb-4">
                <Text variant="heading3">Available Methods</Text>
              </div>
              <div className="space-y-1 text-sm">
                {methods.map((m) => (
                  <div
                    key={m.name}
                    className="flex justify-between py-1 border-b border-kumo-fill last:border-0"
                  >
                    <code className="font-mono text-kumo-default">
                      {m.name}
                    </code>
                    {m.description && (
                      <span className="text-kumo-subtle text-xs">
                        {m.description}
                      </span>
                    )}
                  </div>
                ))}
              </div>
            </Surface>
          )}

          {/* Last Result */}
          {lastResult && (
            <Surface className="p-4 rounded-lg ring ring-kumo-line">
              <div className="mb-2">
                <Text variant="heading3">Last Result</Text>
              </div>
              <HighlightedCode
                code={lastResult}
                lang={lastResult.startsWith("Error:") ? "typescript" : "json"}
              />
            </Surface>
          )}
        </div>

        {/* Logs */}
        <div className="space-y-6">
          <LogPanel logs={logs} onClear={clearLogs} maxHeight="400px" />
        </div>
      </div>

      <CodeExplanation sections={codeSections} />
    </DemoWrapper>
  );
}