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.ClusterAgentclient — direct cluster access. Your backend talks to the cluster as a custody-management API; no device-side share.
Three patterns combine these in production:
| Pattern | Client surface | Where the device share lives | Use case |
|---|---|---|---|
| Embedded Agent | EmbeddedAgent | On the end-user's device | Mobile / browser wallets, self-custody apps |
| Custody backend | ClusterAgent client | (none — keys live entirely in the cluster) | Institutional hot wallets, exchange-grade signing service |
| Hybrid | Both | On the device for user-controlled wallets, in the cluster for system-controlled wallets | Apps 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 thisUse 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
EmbeddedAgentinstance 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
keyIdprefix.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 bykeyShareId → keyId → customerIdbefore 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-
EmbeddedAgentinstance. 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-walletpresignaturePoolStats.availablefor 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'sagent[]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
- Embedded Agent → — full docs for the device-side client.
- ClusterAgent client → — backend-side client surface.
- Configuration → — every cluster knob.