@gemmapod/host
v0.6.0
Published
Local Host runtime for GemmaPod pods. Owns the pod registry, event bus, conversation/state stores, signaling WebRTC bridge to visitors, and the loopback HTTP+dashboard API. Used by the unified `gemmapod` CLI; can also be embedded programmatically.
Maintainers
Readme
@gemmapod/host (local Host runtime)
The piece that runs on the pod owner's machine. Connects to the cloud signaling broker, registers itself for one or more pod ids, and completes WebRTC handshakes locally with each visitor. Chat bytes flow peer-to-peer through the resulting DARTC/WebRTC data channel — the broker never sees them.
What it does
- Opens a persistent connection to the signaling broker (HTTPS → WebSocket upgrade) with exponential-backoff reconnect.
- Sends
{t:"register", podId}. From then on, the cloud routes any visitor offer for that pod id to this socket. - For each incoming
{t:"offer", sessionId, sdp}:- Creates a fresh
RTCPeerConnection(node-datachannel's W3C polyfill). - Completes negotiation, returns
{t:"answer", sessionId, sdp}. - Waits for the visitor's data channel
dartc.v0to open.
- Creates a fresh
- On the data channel: speaks DARTC v0.2 only. The Host verifies the
visitor's signed
dartc.hello, sends its own signeddartc.hello, advertises an A2A-shaped Agent Card ona2a.discovery, then accepts signedgemmapod.chat.requestenvelopes. If a signed manifest is present, the Host verifies it with the same Rust/WASM core used by the browser, replaces the caller-provided system prompt with the signed prompt, and exposes only locally-registered tools whose names appear in the signed manifest. It then streams the response back via the Vercel AI SDK (streamText) against an OpenAI-compatible endpoint (${OLLAMA_URL}/v1/chat/completions) and sends the result as signedgemmapod.chat.deltaenvelopes followed bygemmapod.chat.done.
The browser shim sends a stable pod-scoped conversation_id in
dartc.hello and gemmapod.chat.request. The Host persists conversation
memory keyed by podId + conversation_id in SQLite, so a browser refresh
creates a new WebRTC peer but resumes the same logical chat. By default
the database is ~/.gemmapod/host.sqlite; set GEMMAPOD_HOST_DB to
override it. If the local Node runtime does not expose node:sqlite, the
Host logs a warning and falls back to process memory.
Alongside the chat delta topics, the Host emits signed
gemmapod.ui.event envelopes. These are AG-UI-shaped DARTC-native
events for run lifecycle (RUN_*), assistant text streaming
(TEXT_MESSAGE_*), tool-call visibility (TOOL_CALL_*), state
snapshots/deltas (STATE_* — RFC 6902 JSON Patch), chat history
rehydration (MESSAGES_SNAPSHOT, used after a reconnect to the same
conversation_id), activity panels (ACTIVITY_*), and custom UI actions
(CUSTOM).
The protocol uses Ollama's OpenAI-compat path so the same code works against local Ollama, Ollama Cloud proxied models, or any other OpenAI-shaped server.
DARTC + A2A discovery
Each WebRTC peer gets an ephemeral Ed25519 DARTC session key. DARTC signatures cover the canonical JSON envelope; the signed pod manifest remains the authority for pod identity, system prompt, transport config, and tool allow-list.
Supported topics today:
| topic | purpose |
|-------|---------|
| dartc.hello | Session-key and topic negotiation. |
| dartc.ack | Acknowledge control messages. |
| dartc.error | Signed protocol/application errors. |
| a2a.discovery | Exchange A2A-shaped Agent Cards. |
| gemmapod.chat.request | Browser-to-Host chat request. |
| gemmapod.chat.delta | Origin-to-browser streamed text/reasoning delta. |
| gemmapod.chat.done | End of chat stream. |
| gemmapod.ui.event | Signed frontend/runtime event stream (schema dartc.ui.event/0.1). |
The Host Agent Card is derived from the verified signed manifest: pod name, persona, signed tools as skills, DARTC extension metadata, pod id, and owner public key.
Conversation continuity is intentionally separate from WebRTC identity:
each refresh gets a fresh peer connection and ephemeral DARTC key, while
the signed payload carries the stable conversation_id.
Start the Host
There is no separate "start with Agent Card" command. A2A Agent Card exchange happens automatically after a visitor pod connects:
- The Host registers each pod (from its
pod.toml) with the signaling broker. - A browser pod opens WebRTC and the
dartc.v0data channel. - Both sides exchange signed
dartc.helloenvelopes. - The Host sends its A2A-shaped Agent Card on
a2a.discovery. - Chat continues on signed
gemmapod.chat.*topics.
Day-to-day, you run the Host via the unified gemmapod CLI:
gemmapod start # foreground, no pods yet
gemmapod start --detach # background
gemmapod run ./my-pod # start (if needed) and register one pod
gemmapod run ./my-pod --port 57500 --model gemma4:e4bPod-level settings (signal URL, pod id, owner pubkey, model) live in
each pod's pod.toml. Process-level settings come from env vars or
flags — see Configuration below.
For production, set OWNER_PUBKEY inside the pod's pod.toml. When
set, the Host rejects signed manifests from any other owner before
tools are exposed.
If startup fails with
Cannot find module '../../../build/Release/node_datachannel.node',
rebuild the native WebRTC binding:
pnpm --filter @gemmapod/host rebuild node-datachannelProgrammatic usage
You can embed the Host in your own Node.js script:
import { Host, startDaemon } from "@gemmapod/host";
// Option A: use the Host class directly
const host = new Host({ ollamaUrl: "http://localhost:11434" });
await host.start();
const podId = await host.addPodFromDir("./my-pod");
console.log(`Pod ${podId} running at http://localhost:${host.port()}`);
// Later:
await host.shutdown();
// Option B: convenience helper
const { shutdown } = await startDaemon({
ollamaUrl: "http://localhost:11434",
podDir: "./my-pod",
port: 57500,
});
// Later:
await shutdown();The Host class exposes:
| Member | Description |
|--------|-------------|
| new Host(config) | Create a Host with optional ollamaUrl, apiKey, port, headless, uiDir, discoveryPath. |
| host.start() | Boot the HTTP server, acquire discovery lock, register signal handlers. |
| host.addPodFromDir(dir, overrides?) | Load a pod.toml directory and register the pod with the signaling broker. Returns the pod id. |
| host.resumeFromStateFile() | Re-register previously running pods from the state file. |
| host.shutdown() | Gracefully stop all pods, close the HTTP server, release discovery. |
| host.port() | The loopback port the dashboard is listening on. |
| host.bus | EventBus — subscribe to host/pod lifecyle events. |
| host.conversations | ConversationStore — in-memory or SQLite conversation history. |
| host.registry | PodRegistry — all registered pod runtimes. |
Local development
ollama serve # if not already
ollama pull gemma4:e4b # one-time
pnpm dev:signal # in another shell
pnpm dev:host # this packageConfiguration
Process-level (Host itself):
| env / flag | default | meaning |
|---------------------------|-------------------------------|-------------------------------------------------------------------------|
| --port <n> / GEMMAPOD_PORT | first free in 57447..57456 | Loopback port for the HTTP API + dashboard. |
| OLLAMA_URL | http://localhost:11434 | Where to proxy chat requests. |
| GEMMAPOD_HOST_DB | ~/.gemmapod/host.sqlite | SQLite path for conversation memory. |
| GEMMAPOD_CONTACT_JSON | unset | JSON returned by the built-in share_contact tool. |
| GEMMAPOD_API_KEY | unset | Optional API key for the OpenAI-compatible provider. |
Per-pod (in each pod's pod.toml):
| field | meaning |
|-----------------------------|------------------------------------------------------------------------|
| pod_id | Pod id the Host registers for at the signaling broker. |
| [transport.dartc].signal_url | Signaling endpoint. wss://signal.gemmapod.com/signal by default. (Legacy key [transport.webrtc] still accepted.) |
| [owner].pubkey | Optional Ed25519 owner key the signed manifest must match. |
| model | Default Ollama (or OpenAI-compat) model name. |
Signed tool runtime
Packed pods carry the signed manifest inside DARTC hello/chat payloads.
The Host verifies that manifest, checks it is for the pod's registered
id, optionally checks the owner pubkey, and intersects the signed
[[tools]] allow-list with the local tool registry.
The built-in local tools are:
| tool | behavior |
|--------------------|----------|
| share_contact | Returns GEMMAPOD_CONTACT_JSON, or Raj's default public contact payload. |
| show_project | Returns a short project summary. |
| package_demo_pod | Returns instructions for building/deploying a demo pod. |
Adding custom tools
The Host uses the Vercel AI SDK (ai package) for tool definitions.
Custom tools are built with tool() from ai and zod for input schema
validation.
import { tool } from "ai";
import { z } from "zod";
const myWeatherTool = tool({
description: "Get the current weather for a location.",
inputSchema: z.object({
location: z.string().describe("City name, e.g. 'London'"),
units: z.enum(["celsius", "fahrenheit"]).optional(),
}),
execute: async ({ location, units }) => {
// Call your weather API and return a result
return { temperature: 22, conditions: "sunny", location };
},
});To register a tool with the Host so it's available to the AI agent,
pass it in the tools parameter when constructing the streaming config:
import { streamText } from "ai";
import { createLanguageModel, buildAgentTools } from "@gemmapod/host";
// Tools are composed automatically from:
// - Local tools (share_contact, show_project, package_demo_pod)
// - Manifest-signed tools (from the signed pod manifest)
// - UI event tools (show_presentation, set_state, send_custom_event)
// - Companion tools (react_companion, say_companion — opt-in)
const model = createLanguageModel("gemma4:e4b", "http://localhost:11434");
const tools = buildAgentTools({
systemPrompt: "You are a helpful assistant.",
model: "gemma4:e4b",
ollamaUrl: "http://localhost:11434",
manifest: verifiedManifest,
toolRuntime: myToolRuntime,
sendUiEvent: mySendUiEventFn,
});
const response = await streamText({
model,
system: mySystemPrompt,
messages: [{ role: "user", content: "Hello!" }],
tools,
});UI event tools (Host-provided)
The Host ships UI event tools that emit DARTC CUSTOM events
to drive the visitor's UI. These are registered by the Host, not declared
in the manifest:
| Category | Tools | Description |
|----------|-------|-------------|
| Generic (auto-registered) | show_presentation, set_state, send_custom_event | Work for any host |
| Companion (opt-in) | react_companion, say_companion | For hosts with a 3D avatar |
Generic UI tools are built with buildUiEventTools(sendUiEvent) and
auto-registered when you provide a sendUiEvent callback.
Companion tools are built with buildCompanionTools(sendUiEvent) and
must be passed explicitly via the uiTools config option.
import { tool } from "ai";
import { z } from "zod";
import { buildCompanionTools } from "@gemmapod/host";
const sendUiEvent = async (event) => {
// Send a DARTC ui.event envelope to the visitor
await session.sendUiEvent(event, { stream: true });
};
// Companion tools are opt-in:
const uiTools = buildCompanionTools(sendUiEvent);
// They are passed to buildAgentTools via uiTools:
const tools = buildAgentTools({
// ...
sendUiEvent,
uiTools, // includes react_companion, say_companion
});Unsigned widget mounts still work, but they do not expose tools. A tool call is rejected unless the name is both signed into the manifest and implemented locally by the Host.
In production, start the Host via the unified CLI:
gemmapod start # foreground
gemmapod start --detach # background
gemmapod run ./my-pod # start (if needed) and register a podPer-pod settings (signal URL, pod id, owner pubkey, model) live in each
pod's pod.toml.
End-to-end smoke test
scripts/e2e.ts plays the visitor side over the cloud-mediated path:
# requires signal + Host + ollama all running
pnpm --filter @gemmapod/host exec tsx scripts/e2e.tsA green run streams a Gemma 4 reply over a real DARTC/WebRTC data channel.
Deploy
The owner runs this on their own machine — that's the entire point. There is no managed deploy target. Typical setups:
- Mac mini at home.
gemmapod start --headlessunderlaunchdorpm2. - Raspberry Pi. Same, with Ollama serving smaller Gemma variants.
- VPS / Cloud Run. Works too — the Host only needs outbound HTTPS (WebSocket) and an Ollama endpoint to proxy to.
