GemmaPoddocs
Guides

Add a tool

Declare a tool in the manifest, implement it on the Host, keep the allow-list signed.

GemmaPod tools follow a strict pattern:

  1. Declared in the signed manifest. Pods can never grow new tools at runtime.
  2. Implemented in the Host's local registry. The pod doesn't carry executable tool code.
  3. The Host enforces the intersection. A tool runs only if its name is in both the signed manifest and the local registry.

This is the trust boundary. Tampering with either side breaks the contract closed.

Step 1. Declare in pod.toml

name = "ticketing-bot"
persona = "Helps customers diagnose issues and open tickets."
model = "gemma4:e4b"

system_prompt = """
You help customers. When they describe a problem you can't resolve in
chat, call `open_ticket` with a summary; never invent ticket IDs.
"""

[transport]
preferred = ["dartc", "fallback"]

[transport.dartc]
signal_url = "wss://signal.gemmapod.com/signal"
pod_id = "ticketing-bot"

[[tools]]
name = "open_ticket"
description = "Open a ticket in the company's helpdesk."

[[tools]]
name = "search_kb"
description = "Search the public knowledge base."

Rebuild:

gemmapod build pod.toml --key owner.key --out ticketing-bot.html

The manifest's tool allow-list is now part of the signed CBOR. A tampered blob fails verification.

Step 2. Implement on the Host

The Host's local registry is keyed by tool name. Today the registry ships three built-in tools (share_contact, show_project, package_demo_pod); you can extend it by editing packages/host/src/toolRuntime.ts or — for plug-in style — wrapping the Host with your own runner.

A minimal local registry entry looks like:

const localTools: Record<string, LocalTool> = {
  open_ticket: {
    description: "Open a ticket in the helpdesk.",
    async execute({ summary, severity = "normal" }) {
      const res = await fetch("https://helpdesk.internal/api/tickets", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Authorization": `Bearer ${process.env.HELPDESK_TOKEN}`,
        },
        body: JSON.stringify({ summary, severity }),
      });
      if (!res.ok) throw new Error(`helpdesk: ${res.status}`);
      return { id: (await res.json()).id };
    },
  },

  search_kb: {
    description: "Search the public knowledge base.",
    async execute({ query, limit = 5 }) {
      const res = await fetch(
        `https://help.example.com/api/search?q=${encodeURIComponent(query)}&limit=${limit}`,
      );
      return await res.json();
    },
  },
};

A future release will expose this as a registration API (host.registerTool(name, fn)) so you don't fork the package. For v0.1, fork or wrap.

UI tools are separate from manifest tools

Manifest tools (like open_ticket above) are signed — they must be declared in pod.toml and verified at runtime. UI tools are not signed; they are registered by the Host at agent creation time and drive the visitor's UI through DARTC events.

The Host ships two categories of UI tools:

CategoryToolsRegistration
Genericshow_presentation, set_state, send_custom_eventAuto-registered when sendUiEvent is provided
Companion-specificreact_companion, say_companionOpt-in via uiTools: buildCompanionTools(...)

If you're building a host embed without a 3D avatar, you don't need companion tools. If you're building a custom dashboard, you can add your own UI tools that emit CUSTOM events your embed understands.

See UI tools and companion plugins for the full pattern.

Step 3. Run with OWNER_PUBKEY set (production)

In production, set OWNER_PUBKEY in the pod's pod.toml so the Host only accepts manifests signed by your key. Otherwise anyone running a pod against your pod_id could request your tools.

gemmapod run ./ticketing-bot

How the call flows

  1. The pod's WebRTC channel opens; both sides exchange signed dartc.hello envelopes carrying the signed manifest.
  2. The Host verifies the manifest, checks OWNER_PUBKEY if set, intersects the manifest's [[tools]] list with the local registry.
  3. The model decides to call open_ticket. The Host invokes localTools.open_ticket.execute(args) and streams the result back as a signed gemmapod.ui.event (TOOL_CALL_STARTTOOL_CALL_ARGSTOOL_CALL_ENDTOOL_CALL_RESULT) plus an assistant TEXT_MESSAGE_* reply.
  4. The browser runtime fires ui.event on its bus. Custom UIs render tool activity inline (see restaurant-pod).

See also