Skip to content

Building a Custom Client

Any WebSocket client that speaks the tRPC v11 wire protocol can connect to a Molf server. This page covers the connection setup, core API workflow, event handling, and tool approval -- everything needed to build a client from scratch.

Connecting

URL and Authentication

The server listens on wss://host:port (TLS by default, port 7600). Authentication is sent via the Authorization: Bearer {token} header on the WebSocket handshake. Two query parameters identify the client:

ParameterDescription
clientIdA UUID identifying this client instance
nameA human-readable name (e.g. "my-app")

TLS with Self-Signed Certificates

The server auto-generates a self-signed EC certificate by default. Clients connecting from Node.js need to handle this -- either:

  • Provide a CA file via --tls-ca / MOLF_TLS_CA (if using a proper CA)
  • Use the createAuthWebSocket helper from @molf-ai/protocol, which accepts TLS options like ca, rejectUnauthorized, and checkServerIdentity
  • Use probeServerCert from @molf-ai/protocol to implement TOFU (Trust On First Use) -- probe the server's certificate, display the fingerprint, and pin it for future connections

tRPC Client Setup

Using @trpc/client with the ws package:

typescript
import { createTRPCClient, createWSClient, wsLink } from "@trpc/client";
import type { AppRouter } from "@molf-ai/server";
import { createAuthWebSocket } from "@molf-ai/protocol";

const token = process.env.MOLF_TOKEN!;
const serverUrl = "wss://127.0.0.1:7600";

// createAuthWebSocket returns a WebSocket subclass that injects
// the Authorization header and applies TLS options
const AuthWebSocket = createAuthWebSocket(token, {
  // For self-signed certs, pass TLS opts here:
  // ca: readFileSync("/path/to/ca.pem"),
  // rejectUnauthorized: false,  // only for development
});

const url = new URL(serverUrl);
url.searchParams.set("clientId", crypto.randomUUID());
url.searchParams.set("name", "my-client");

const wsClient = createWSClient({
  url: url.toString(),
  WebSocket: AuthWebSocket,
  retryDelayMs: (attempt) => {
    // Exponential backoff with jitter
    const delay = Math.min(1000 * 2 ** attempt, 30_000);
    return Math.round(delay + delay * 0.25 * (Math.random() * 2 - 1));
  },
});

const trpc = createTRPCClient<AppRouter>({
  links: [wsLink({ client: wsClient })],
});

For unauthenticated connections (e.g. during a pairing flow), use createUnauthWebSocket instead.

Core API Workflow

A typical client session follows these steps:

1. Ensure workspace  ->  2. Create/load session  ->  3. Subscribe to events
      ->  4. Send prompts  ->  5. Handle tool approvals

Step 1: Ensure a Workspace

Workspaces group sessions and carry per-workspace configuration (like model overrides).

typescript
const { workspace, sessionId } = await trpc.workspace.ensureDefault.mutate({
  workerId,
});

Or create a named workspace:

typescript
const { workspace, sessionId } = await trpc.workspace.create.mutate({
  workerId,
  name: "My Project",
});

Step 2: Create or Load a Session

Create a new session within a workspace:

typescript
const created = await trpc.session.create.mutate({
  workerId,
  workspaceId: workspace.id,
});
const sessionId = created.sessionId;

Or load an existing session:

typescript
const loaded = await trpc.session.load.mutate({ sessionId: "existing-id" });
// loaded.messages contains the full message history

Step 3: Subscribe to Events

Subscribe before sending a prompt to avoid missing early events:

typescript
const subscription = trpc.agent.onEvents.subscribe(
  { sessionId },
  {
    onData(event) {
      // Handle events (see Event Types below)
    },
    onError(err) {
      console.error("Subscription error:", err);
    },
  },
);

Step 4: Send a Prompt

typescript
await trpc.agent.prompt.mutate({
  sessionId,
  text: "List files in the current directory",
  // Optional overrides:
  // model: "anthropic/claude-sonnet-4-20250514",
  // fileRefs: [{ path: "/path/to/file", mimeType: "image/png" }],
});

The prompt call is fire-and-forget -- it returns immediately. Results arrive through the event subscription.

Step 5: Handle Tool Approvals

When the agent makes a tool call that requires approval, a tool_approval_required event is emitted. Respond with one of:

typescript
// Approve once
await trpc.tool.approve.mutate({ sessionId, approvalId });

// Always approve this tool+pattern (persisted to permissions.jsonc)
await trpc.tool.approve.mutate({ sessionId, approvalId, always: true });

// Deny with optional feedback (sent back to the LLM as the tool result)
await trpc.tool.deny.mutate({ sessionId, approvalId, feedback: "Too risky" });

Both tool.approve and tool.deny return { applied: boolean }. If applied is false, the approval was already resolved or the agent was aborted.

Event Types

The agent.onEvents subscription emits 9 event types:

EventKey FieldsDescription
status_changestatusAgent status changed: idle, streaming, executing_tool, error, aborted
content_deltadelta, contentStreaming text chunk (delta is the new fragment, content is accumulated)
tool_call_starttoolName, arguments, toolCallIdTool execution began
tool_call_endtoolCallId, resultTool execution finished
turn_completemessageFull assistant message with all tool calls and content
errorcode, messageError during agent execution
tool_approval_requiredapprovalId, toolName, arguments, sessionIdTool call needs user approval
context_compactedsummaryMessageIdContext was automatically summarized (informational, no action needed)
subagent_eventagentType, sessionId, eventWrapper around a child agent's event

Handling Subagent Events

When the agent spawns a subagent via the task tool, the child's events arrive wrapped in subagent_event:

typescript
if (event.type === "subagent_event") {
  const inner = event.event;
  console.log(`[@${event.agentType}] ${inner.type}`);

  // Tool approvals from subagents must be handled the same way
  if (inner.type === "tool_approval_required") {
    await trpc.tool.approve.mutate({
      sessionId: inner.sessionId,
      approvalId: inner.approvalId,
    });
  }
}

Reconnect Replay

When a client reconnects and re-subscribes to agent.onEvents, the server automatically replays any pending tool_approval_required events for that session.

Key Procedures

The server exposes 9 tRPC routers. Here are the procedures most relevant to client development:

RouterProcedureTypeDescription
sessioncreatemutationCreate a session (workerId + workspaceId)
sessionloadmutationLoad a session and its messages
sessionlistqueryList sessions (pagination 1-200)
sessiondeletemutationDelete a session
sessionrenamemutationRename a session
agentlistqueryList workers with tools, skills, agents
agentpromptmutationSend a prompt (fire-and-forget)
agentonEventssubscriptionStream agent events for a session
agentabortmutationCancel the running agent
agentstatusqueryGet current agent status for a session
agentshellExecmutationRun a shell command on the worker
agentuploadmutationUpload a file (15 MB max, base64)
toollistqueryList available tools
toolapprovemutationApprove a pending tool call
tooldenymutationDeny a pending tool call
workspaceensureDefaultmutationGet or create the default workspace
workspacecreatemutationCreate a workspace (also creates a first session)
workspacesetConfigmutationSet workspace config (e.g. model)
workspaceonEventssubscriptionStream workspace events
providerlistModelsqueryList available models
authcreatePairingCodemutationGenerate a 6-digit pairing code
authredeemPairingCodemutationExchange code for API key (public, rate-limited)

For the complete API, see Protocol Reference.

Example: Minimal Client

A complete Node.js script that connects, creates a session, sends a prompt, and prints the response:

typescript
import { createTRPCClient, createWSClient, wsLink } from "@trpc/client";
import WebSocket from "ws";
import type { AppRouter } from "@molf-ai/server";

const token = process.env.MOLF_TOKEN!;
const url = `ws://127.0.0.1:7600?clientId=${crypto.randomUUID()}&name=example`;

const wsClient = createWSClient({ url, WebSocket });
const trpc = createTRPCClient<AppRouter>({
  links: [wsLink({ client: wsClient })],
});

// Note: this example uses ws:// (no TLS) for simplicity.
// In production, use wss:// with createAuthWebSocket.

async function main() {
  const { workers } = await trpc.agent.list.query();
  const worker = workers.find((w) => w.connected);
  if (!worker) throw new Error("No workers connected");

  const { workspace } = await trpc.workspace.ensureDefault.mutate({
    workerId: worker.workerId,
  });

  const session = await trpc.session.create.mutate({
    workerId: worker.workerId,
    workspaceId: workspace.id,
  });

  const done = new Promise<void>((resolve) => {
    trpc.agent.onEvents.subscribe(
      { sessionId: session.sessionId },
      {
        onData(event) {
          if (event.type === "content_delta") {
            process.stdout.write(event.delta);
          } else if (event.type === "turn_complete") {
            console.log("\n--- Done ---");
            resolve();
          } else if (event.type === "error") {
            console.error(`\nError: ${event.message}`);
            resolve();
          }
        },
      },
    );
  });

  await trpc.agent.prompt.mutate({
    sessionId: session.sessionId,
    text: "Hello! What tools do you have available?",
  });

  await done;
  wsClient.close();
}

main().catch(console.error);

See Also