Cluster Agent

Integration patterns

Connect your cluster to the right client surface. Embedded Agent for device + cloud agents, ClusterAgent client for server-side custody, hybrid for both.

Integration patterns

A running MPC Agent cluster is one half of the integration; the other half is whoever talks to it. The supported client surfaces in @zafeguard/mpc-sdk are:

  • EmbeddedAgent — the device-side facade. Your application holds one share; the cluster holds the rest.
  • ClusterAgent client — direct cluster access. Your backend talks to the cluster as a custody-management API; no device-side share.

Three patterns combine these in production:

PatternClient surfaceWhere the device share livesUse case
Embedded AgentEmbeddedAgentOn the end-user's deviceMobile / browser wallets, self-custody apps
Custody backendClusterAgent client(none — keys live entirely in the cluster)Institutional hot wallets, exchange-grade signing service
HybridBothOn the device for user-controlled wallets, in the cluster for system-controlled walletsApps that mix self-custody + custodian-issued wallets

Each pattern is laid out below with a complete code path.


Pattern 1 — Embedded Agent

The canonical pattern. End-user devices each hold one MPC share; your cluster holds the rest. Signing requires the device AND a threshold of agents to cooperate. The cluster cannot sign on its own — neither can the device alone.

                ┌────────────────────┐
                │  User's device     │
                │  EmbeddedAgent    │  ← one share lives here
                └─────────┬──────────┘
                          │ HTTPS (api key)

              ┌───────────┴───────────┐
              │  Your cluster (3 nodes)│
              │  ┌───┐  ┌───┐  ┌───┐  │  ← shares 2..N live here
              │  │ 1 │  │ 2 │  │ 3 │  │
              │  └───┘  └───┘  └───┘  │
              └───────────────────────┘

Client code (device):

import { EmbeddedAgent, Curve, RecoveryKind } from '@zafeguard/mpc-sdk';

const signer = new EmbeddedAgent({
  agents: [
    { baseUrl: 'https://node-1.mpc.yourcompany.com', apiKey: USER_API_KEY },
    { baseUrl: 'https://node-2.mpc.yourcompany.com', apiKey: USER_API_KEY },
    { baseUrl: 'https://node-3.mpc.yourcompany.com', apiKey: USER_API_KEY },
  ],
  threshold: 2,
  curve: Curve.Secp256k1,
});

const loaded = await signer.create({
  keyId: `user-${userId}-eth-wallet`,
  recovery: { kind: RecoveryKind.Noop },
});

Cluster config — standard 3-node deployment (see Deployment). No special configuration; this is what the cluster is designed for by default.

API key strategy. Each user device gets its own API key (issued by your backend on user signup; rotate periodically). The agent enforces per-key rate limits. For multi-tenant deployments, scope keys by tenant so one tenant's traffic can't blow another's quota.


Pattern 2 — Custody backend

The cluster holds the entire MPC cohort; there is no device-side share. Your backend uses the ClusterAgent client to drive ceremonies from the server. Use this for institutional hot wallets, exchange signing services, payment processors — anywhere the keys MUST stay inside your infrastructure.

                ┌─────────────────────┐
                │  Your backend       │
                │  ClusterAgent client    │  ← orchestrates ceremonies
                └──────────┬──────────┘
                           │ HTTPS (api key)

              ┌───────────┴───────────┐
              │  Your cluster (3 nodes)│
              │  ┌───┐  ┌───┐  ┌───┐  │  ← every share lives here
              │  │ 1 │  │ 2 │  │ 3 │  │
              │  └───┘  └───┘  └───┘  │
              └───────────────────────┘

Client code (server):

import { ClusterAgent } from '@zafeguard/mpc-sdk';

const agent = ClusterAgent.connect('node-1.mpc.yourcompany.com', 443, API_KEY, true);

// Mint a fresh 2-of-3 wallet across the cluster.
const session = await agent.sessions.createKeyShare({
  keyId: `wallet-${walletId}`,
  curve: 'SECP256K1',
  threshold: 2,
  maxShares: 3,
  peers: peerList,                      // your three nodes
});
const keyShare = await session.untilTerminal();

// Mint presignatures.
const presigSession = await agent.sessions.createPresignature({ /* ... */ });

// Sign.
const partial = await agent.presignature.sign(presigId, { messageHashB64 });

// Verify locally — the cluster never assembles the final signature.
const final = Scheme.finalizeSignature(/* partials, message */);

Cluster config — same standard 3-node deployment. The cluster doesn't know or care that the client is a backend instead of a device.

Operational notes. Backend-only setups skip the device-share complexity but lose self-custody. Compromising your backend's API key + cluster network access = unconstrained signing. Mitigations: short-lived API keys via JWT (api.auth.auth_type: jwt in the agent config), per-call policy webhooks (policy_webhooks.pre_sign) for transaction-level gating, separate cohorts per logical use case so a leaked key blast radius stays bounded.


Pattern 3 — Hybrid

Some apps want both: end-user wallets (Embedded Agent) AND system wallets the app controls directly (custody backend). One cluster serves both — different wallets in the same cluster have different topologies.

                ┌─────────────────────┐
                │  Your backend       │
                │  ClusterAgent client    │   ── orchestrates the system wallets
                └──────────┬──────────┘
                           │ HTTPS
                ┌──────────┴──────────────────────┐
                │  Your cluster                   │
                │  ┌───┐  ┌───┐  ┌───┐            │
                │  │ 1 │  │ 2 │  │ 3 │            │
                │  └───┘  └───┘  └───┘            │
                └──────────┬──────────────────────┘
                           │ HTTPS
                ┌──────────┴──────────┐
                │  User's device      │   ── orchestrates the user wallets
                │  EmbeddedAgent     │
                └─────────────────────┘

Cluster config. Single 3-node deployment, two different API-key audiences:

# In the agent config:
api:
  auth:
    auth_type: api_key
    api_keys:
      - "${BACKEND_API_KEY}"        # Your custody backend
      - "${DEVICE_USER_KEY_BASE}"   # Per-user device keys derive from this

Use API keys to separate the two audiences for rate limiting + audit:

  • Backend key. Long-lived, high rate limit, used by your custody service. Rotate via deploy.
  • Device keys. Per-user, short-lived, lower rate limit. Your backend issues them to devices on login; renew periodically.

Same cluster, different signer types. Wallets created via ClusterAgent.sessions.createKeyShare from the backend never have a device party — every share is in the cluster, signing is server-only. Wallets created via EmbeddedAgent.create({ keyId, recovery, passphrase }) from a device DO have a device party; signing requires the device. Both can coexist in the same cluster; the cluster just sees them as different key_id values.

Policy webhooks. Often you want different policy gating for different audiences:

policy_webhooks:
  pre_sign:
    url: "https://policy.yourcompany.com/mpc/pre-sign"
    secret: "${POLICY_WEBHOOK_SECRET}"

Your webhook endpoint differentiates by request body (key_id namespace, requester_api_key lookup) and enforces different policies — e.g., backend-issued signs go through your transaction-rules engine; user-issued signs only need MFA confirmation.


Pattern 4 — Multi-tenant SaaS platform

You are an embedded-wallet-as-a-service provider. Your platform hosts wallets on behalf of N customer fintechs; each fintech has thousands of their end-user wallets. The platform team has admin access to all cohorts but enforces per-tenant isolation — no fintech's wallets can sign as another fintech's wallets, and no fintech's pool depth, audit logs, or recovery bundles leak across tenant boundaries.

Isolation primitive: the keyId (caller-chosen, stable across the cohort) is the tenant boundary. Every wallet's keyId MUST be scoped to the owning fintech — typically tenant:${customerId}:wallet:${walletId} or a UUID derived from the customer id. Two fintechs MUST NOT see each other's keyId namespace.

Architectural shape:

  • One ClusterAgent.connect(...) per cluster node, shared across all tenants — the SDK has no per-tenant credential, and the cluster API key is a platform secret.
  • One EmbeddedAgent instance per customer fintech if you're running embedded shape (wallets have a device party); use a dedicated key-management pod / VM per customer so the in-memory share never crosses the JS heap boundary between tenants. For pure cluster-only wallets, no per-tenant SDK instance is needed.
  • Per-tenant queries: when listing or operating on wallets, always filter by the customer's keyId prefix. agent.keyShares.list() returns every share on the local node — your access-control layer is responsible for filtering by ownership, not the SDK.
  • Per-tenant audit: query agent.sessions.auditLogs({ limit, since }) for the whole cluster, then filter the returned rows by keyShareId → keyId → customerId before exposing to a tenant's dashboard. The SDK doesn't expose a per-tenant filter; that's an integrator-side join.
  • Quota isolation: presignature pool auto-refill is per-EmbeddedAgent instance. For a multi-tenant platform with shared cluster agents, large tenants can starve smaller ones on agent throughput unless you cap per-tenant presign rate at the platform layer. The SDK provides per-wallet presignaturePoolStats.available for observability; quota allocation is your platform's responsibility.
function clusterAgent() {
  return ClusterAgent.connect(NODE_HOST, NODE_PORT, PLATFORM_API_KEY, true);
}

// Per-tenant wallet creation — keyId prefix is the isolation gate.
async function createWalletForTenant(customerId: string, walletId: string) {
  const keyId = `tenant:${customerId}:wallet:${walletId}`;
  const signer = new EmbeddedAgent({
    agents: [{ baseUrl: NODE_URL, apiKey: PLATFORM_API_KEY }],
    threshold: 2,
    curve: Curve.Secp256k1,
  });
  return signer.create({
    keyId,
    recovery: { kind: RecoveryKind.Noop },
    passphrase: await hsm.deriveKey('platform-export', customerId, walletId),
  });
}

// Per-tenant audit slice.
async function tenantAuditLog(customerId: string, sinceMs: number) {
  const agent = clusterAgent();
  const rows = await agent.sessions.auditLogs({ limit: 100_000 });
  const prefix = `tenant:${customerId}:`;
  return rows.filter((r) =>
    r.context?.keyId?.startsWith(prefix) &&
    Date.parse(r.timestamp) >= sinceMs
  );
}

Boundary that the SDK does NOT enforce: there is no hard guarantee at the cluster layer that customer A cannot trigger signing on customer B's keyId. Tenant isolation lives at your platform's access-control layer, not inside ClusterAgent. Treat the keyId namespace as a convention enforced by your code; never expose a raw keyId to a tenant API and never let one tenant supply a keyId for another's request.


Picking a pattern

┌─────────────────────────────────────────────────────────────────┐
│                                                                 │
│   "Where should the keys live?"                                 │
│                                                                 │
│        ┌────────────────────────────────────────────┐           │
│        │                                            │           │
│        ▼                                            ▼           │
│   Device + cluster                          Cluster only        │
│   (self-custody)                            (institutional)     │
│        │                                            │           │
│        ▼                                            ▼           │
│   EmbeddedAgent                            ClusterAgent client     │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

   "Both?" → Hybrid. One cluster, two client surfaces.

Most consumer-wallet integrations are Pattern 1. Most institutional / payment integrations are Pattern 2. Pattern 3 is for products that span both segments (e.g. an exchange that holds custodial hot wallets but also offers users self-custody).

The cluster is the same in all three patterns. The client-side code differs.


Network topology checklist

Whichever pattern you pick, plan the network layer:

  • Each agent node has its own public DNS name (typically node-1.mpc.yourcompany.com, etc.). EmbeddedAgent's agent[] array lists them; failover happens client-side by trying the next URL when one is unreachable.
  • TLS termination can be at the load balancer (ACM + ALB recommended) or on the agent itself (api.tls.source: file). Pick one; never run plain HTTP in production.
  • The intra-cluster transport broker is intra-cluster only. Never expose its port to the public internet. The broker credentials are a secret, but defense-in-depth says close the port at the firewall too.
  • Outbound from agents — if you use policy_webhooks, agents need outbound HTTPS to your policy endpoints. Allow-list those URLs in your egress firewall.

Next

On this page