Skip to content

Relay client

RelayClient writes to a context through a relay — another node that runs the method on your behalf, under a warrant you signed.

It is the path for a caller that cannot execute at all. sdk.rpc.execute asks a node to run a method as itself: it needs a node, that node has to be a member, and you need a credential on it. A browser tab, a phone, or an agent has none of the three — the runtime cannot compile WASM against materialized state, never received the scope key that seals the deltas, and has no account on anybody’s node.

What makes the resulting write yours rather than the relay’s is the warrant: the delta carries your consent, so every peer verifies you asked rather than taking the relay’s word. It is attributed to your account and device — your replica slot, your membership, your name in the app. See Core → Delegated Authorship for the protocol.

import {
RelayClient,
createLocalStorageNonceSource,
} from '@calimero-network/mero-js';
const relay = new RelayClient({
relayUrl: 'https://relay-01.tee.calimero.network',
executorAccount, // optional; checked against what the relay answers
authorAccount,
authorProof,
deviceSecret,
nonces: createLocalStorageNonceSource(`warrant-nonce:${devicePublicKey}`),
});
const { rootHash, returns } = await relay.execute(contextId, 'set', { key: 'k', value: 'v' });

For the hosted tier, connectCloud builds this for you from a cloud sign-in: it finds the relay and its executor account, and picks a persisted nonce source.

interface RelayClientConfig {
relayUrl: string; // the node's origin, not a path
executorAccount?: string; // hex; checked against the relay's answer
authorAccount: string; // hex — whose consent the warrant carries
authorProof: string; // hex borsh AccountProof<DeviceCert>
deviceSecret?: string; // hex ed25519 seed; NEVER transmitted
signer?: Signer; // or: a key that cannot be exported (exactly one)
nonces: NonceSource;
ttlSeconds?: number; // default 300
fetch?: typeof fetch;
timeoutMs?: number; // default 10000
}

executorAccount is only a check; see what a warrant names.

deviceSecret signs in your process and only the signature leaves. That is the whole reason a keyholder can author without a node.

A key that cannot be exported to hex (a non-extractable WebCrypto key, a passkey, a hardware or remote signer) goes in signer instead: anything with a publicKey (64 hex) and an async sign(payload) returning a 64-byte Ed25519 signature over the payload as given. signerFromCryptoKey(privateKey, publicKey) wraps a WebCrypto key; signerFromSecret(hex) wraps a seed. signWarrant, signCreationWarrant and signGovernanceWarrant take the same pair of options.

const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify']);
const relay = new RelayClient({
relayUrl, authorAccount, authorProof, nonces,
signer: await signerFromCryptoKey(pair.privateKey, pair.publicKey),
});

Every warrant names the relay twice: executor, its account, and executor_key, the one signing key of that account that may spend it. A relay refuses a warrant naming any other key, so one warrant is spent by one relay node, once. Only the relay can report its key, so the client asks it (describe, describeCreation or describeGovernance), even when executorAccount is configured, and refuses before taking a nonce when a configured account disagrees or the relay names no key. A relay that re-keys voids the warrants it has not spent yet.

An intent warrant also pins the release the context’s group names: release_bytecode_id, the blob id of its bytecode, and release_version, its semver (signed, never compared, at most 256 bytes of UTF-8). The relay refuses a warrant pinning any other release, before it runs anything, so a warrant signed against one build cannot be spent after an upgrade widens what the method does. describe reports both, and rejects with discovery’s 404 while the group names no release.

In all three warrants executor_key (32 bytes) comes right after executor, in the signed preimage and in the borsh encoding alike:

intent: context ‖ author_account ‖ author_device_key ‖ executor ‖ executor_key ‖ release_bytecode_id ‖ release_version ‖ method ‖ …
creation: group ‖ seed ‖ author_account ‖ author_device_key ‖ executor ‖ executor_key ‖ application_id ‖ …
governance: scope ‖ kind ‖ author_account ‖ author_device_key ‖ executor ‖ executor_key ‖ op_hash ‖ …

release_version is a borsh String in the encoding (a u32 little-endian length, then the UTF-8) and its own length-prefixed part in the preimage. signWarrant, signCreationWarrant and signGovernanceWarrant take the key as executorKey (hex), signWarrant takes the release as releaseBytecodeId (hex) and releaseVersion, and core pins the bytes in crates/account/src/tests/{warrant,creation,governance}_wire_fixture.rs.

describe(contextId): Promise<RelayDescription>

Section titled “describe(contextId): Promise<RelayDescription>”

What the relay can do here — read this before signing anything.

interface RelayDescription {
executorAccount: string; // what the warrant's `executor` must name
executorKey: string; // what its `executor_key` must name: the relay's signing key
canAuthorOnBehalf: boolean; // does the relay hold the grant on the owning group?
groupId: string; // whose admin grants it
releaseBytecodeId: string; // the release the group names: what the warrant pins
releaseVersion: string; // its semver, signed alongside it
}

canAuthorOnBehalf: false is an answer, not an error, and it is the default state of every context — CAN_AUTHOR_ON_BEHALF is implied by neither membership nor admin, and is not propagated by the subgroup cascade. Read it to tell “ask an admin of groupId to grant it to executorAccount” from “retry later”.

Checking first is not politeness: minting a warrant spends a nonce from a monotonic per-device sequence, and one naming the wrong executor is unspendable — the number is gone and the write never happened.

execute(contextId, method, argsJson?): Promise<IntentResult>

Section titled “execute(contextId, method, argsJson?): Promise<IntentResult>”

Mint a warrant for method(argsJson) and present it.

interface IntentResult<T = unknown> {
rootHash: string; // the context's scope root after the run — did this change anything?
returns: T | null; // the method's own return value
}

One intent, once. The warrant authorizes exactly this method and these arguments in exactly this context, and its nonce is spent by the network on apply — nothing accumulates and nothing is reusable.

Every call runs describe first and signs what it answers, so a warrant pins the release the group names now and follows a relay that re-keyed. A group that names no release yet surfaces as discovery’s 404 HTTPError, before a nonce is taken.

describeCreation(groupId, { author? }): Promise<CreationDescription>

Section titled “describeCreation(groupId, { author? }): Promise<CreationDescription>”

What the relay can do about creating contexts in a group — GET /admin-api/groups/{groupId}/context-intents[?author=].

interface CreationDescription {
executorAccount: string; // what the creation warrant's `executor` must name
executorKey: string; // and its `executor_key`
groupId: string;
canCreateOnBehalf: boolean; // relay standing: RelayTee role or CAN_AUTHOR_ON_BEHALF
authorMayCreate?: boolean; // only with `author`: CAN_CREATE_CONTEXT or admin
}

createContext(input): Promise<CreatedContext>

Section titled “createContext(input): Promise<CreatedContext>”

Create a context in a group without a node. You sign a creation warrant locally; the relay publishes it, every peer checks your CAN_CREATE_CONTEXT (the relay needs no create rights of its own), and the app’s init runs as your account.

const { contextId, groupId, memberPublicKey } = await relay.createContext({
groupId,
applicationId,
initArgs: { name: 'general' }, // what init() receives; default {}
name: 'general', // optional display name
serviceName: undefined, // optional, for multi-service bundles
seed: undefined, // optional hex 32; random when omitted
});

It always calls describeCreation (with your account) first and throws IntentRefusedError before taking a nonce when the relay has no standing or you may not create. The warrant’s nonce comes from the same NonceSource — it is spent in the new context’s per-device ledger. Over a sealed fetch the request is sealed like execute; a proxy-auth relay accepts it only once core serves the route without a credential.

signCreationWarrant, parseCreationWarrant and creationInitHash are exported for callers that mint the warrant themselves.

describeGovernance(groupId): Promise<GovernanceDescription>

Section titled “describeGovernance(groupId): Promise<GovernanceDescription>”

Whether the relay may publish governance ops for members of a group: GET /admin-api/groups/{groupId}/governance-intents.

interface GovernanceDescription {
executorAccount: string; // what the governance warrant's `executor` must name
executorKey: string; // and its `executor_key`
groupId: string;
canActOnBehalf: boolean; // relay standing: RelayTee role or CAN_AUTHOR_ON_BEHALF
}

Publish one governance op without a node: add someone to a group, change a role, create a subgroup. You sign a governance warrant over the op’s exact borsh bytes; the relay wraps it and publishes it, and every peer applies the op as you, so your rights decide (MANAGE_MEMBERS to add a member, admin to add an admin, CAN_CREATE_SUBGROUP to create a subgroup). The relay needs only standing to act for members.

import { memberAddedOp, subgroupCreation } from '@calimero-network/mero-js';
// A group op, published on the group itself.
await relay.govern({ groupId, op: memberAddedOp(otherAccount, 'Member') });
// A root op, published on the NAMESPACE. A subgroup's id is derived from its
// create (admin, parent, visibility and a random salt), so derive it first.
const { groupId: channel, op } = await subgroupCreation({
parentId: namespaceId,
restricted: true,
admin: myAccount,
});
await relay.govern({ groupId: namespaceId, op });

The op encoders produce each op in its delegable form: memberRemovedOp, memberLeftOp and groupDeletedOp leave the post-state hashes and the cascade lists empty, for the relay to fill in from its own view. The others are memberAddedOp, memberRoleSetOp, groupCreatedOp and groupReparentedOp. The warrant’s scope is groupId, so it cannot be spent in another group, and the op’s plane (group or root) is signed over with it.

Like createContext, it calls describeGovernance first and throws IntentRefusedError before taking a nonce when the relay has no standing. An op your rights do not cover comes back from the relay as an IntentRefusedError with status 403.

signGovernanceWarrant, parseGovernanceWarrant and governanceOpHash are exported for callers that mint the warrant themselves.

foundNamespace(input?): Promise<FoundedNamespace>

Section titled “foundNamespace(input?): Promise<FoundedNamespace>”

Found a namespace without a node, with you as its founder, owner and admin. You sign a governance warrant (kind root) over the exact genesis, namespaceCreatedOp({ founder, credential, salt }), scoped to the namespace id foundedNamespaceId(founder, salt). The id is derived from your account and the salt, so the relay can neither pick another id nor found it for anyone else. It posts to POST /admin-api/groups/{namespaceId}/governance-intents.

import { CAPABILITY_PRESETS } from '@calimero-network/mero-js';
const { namespaceId, salt, teeEnabled, defaultCapabilitiesSet } = await relay.foundNamespace({
// The namespace does not exist yet, so there is nothing to describe: the key
// is the relay's node key, pinned or from `resolveRelayNodeKey(relayUrl)`.
executor: { executorAccount: relayAccount, executorKey: relayKey },
// optional: your app's default member mask (COLLABORATOR = 231; see Capabilities)
defaultCapabilities: CAPABILITY_PRESETS.COLLABORATOR,
});
interface FoundNamespaceInput {
salt?: string; // 32 bytes hex; random when absent
executor: { executorAccount: string; executorKey: string }; // the relay's node key, never a discovery answer
defaultCapabilities?: number; // MemberCapabilities bits, set right after founding
}
interface FoundedNamespace {
namespaceId: string;
salt: string; // keep it: (founder, salt) proves who founded the namespace
teeEnabled: boolean; // the relay admitted itself as the namespace's first TEE
teeError?: string; // why not, when it tried and failed
defaultCapabilitiesSet?: boolean; // only when defaultCapabilities was given
defaultCapabilitiesError?: string;
}

The relay is seated in the new namespace as its founding relay (a Member holding CAN_AUTHOR_ON_BEHALF), so govern, createContext and execute work through it straight away. A TEE relay then admits itself as the namespace’s first TEE (GroupOp::FoundingRelayAttested), which also sets a default relay-mode admission policy; teeEnabled says whether it did. The namespace is founded either way.

A namespace is founded with core’s minimal default mask (CAN_JOIN_OPEN_SUBGROUPS). With defaultCapabilities, you then set your own through the same relay: a delegated DefaultCapabilitiesSet (defaultCapabilitiesSetOp(mask)) on the namespace root, under a second warrant and nonce, applied as you, the admin. It may not include CAN_AUTHOR_ON_BEHALF (bit 9), which is refused before anything is signed. A failure there does not throw, since the namespace already exists: defaultCapabilitiesSet is false and defaultCapabilitiesError says why.

There is no describe step: without executor it throws before taking a nonce. A salt that names an existing namespace comes back as an HTTPError with status 409.

A refusal (400 or 403) surfaces as IntentRefusedError rather than a bare HTTPError, carrying the relay’s own explanation:

class IntentRefusedError extends Error {
reason: string; // the relay's explanation, verbatim
retryable: boolean; // true only when a FRESH warrant may work (spent nonce)
status: number; // 400 or 403
}

The distinction is the whole reason the type exists. A 403 from this endpoint means one of three unrelated things and they send you somewhere completely different:

Reason What to do
the relay holds no CAN_AUTHOR_ON_BEHALF grant ask an admin of the group — retryable: false
the author’s account is not a member of the group get an invitation — retryable: false
the warrant’s nonce is already spent re-present under a fresh warrant — retryable: true

A 500 stays an HTTPError: that is the node’s problem, not a precondition of yours. A transport failure is an HTTPError with status: 0, as everywhere else in this SDK. Its message carries what the injected fetch threw, so a sealing failure from createAttestedSealedFetch reads as, say, HTTP 0 Error: The node refused to attest: HTTP 404.

A warrant’s nonce is not a local detail. It is spent, once, per (context, author device) on every node that applies the delta, and the client is the only party that can keep the sequence.

The receiving ledger is a sliding window 64 wide, not a high-water mark, which makes the two failure modes very different:

  • Skipping numbers is free. Gossip gives no ordering, so the window exists precisely to accept whatever unseen nonce arrives.
  • Restarting the sequence is fatal. A tab that reloads and resumes at 1 re-presents nonces the network has already spent, and every one of those writes is refused as a replay — indistinguishable, to the user, from the app silently not saving.
createLocalStorageNonceSource(key, storage?) // persisted; survives a reload
createMemoryNonceSource(start = 1) // for a process that owns its whole sequence

Key it per author device, not per account: two devices of one account are independent replicas with independent sequences, and sharing a counter would have them refusing each other’s warrants.

Implement NonceSource yourself for anything else — it is one method, next(): Promise<bigint>, and must never go backwards.

POST /admin-api/contexts/:id/intents normally sits behind the node’s auth guard. A node run as a relay serves that route and its GET twin without a node credential, because they carry their own: the warrant commits to this context, method and arguments, is single-use, and is refused before execution unless the relay holds the authorship grant.

On the node that is merod init --delegated-access (or server.admin.delegated_access; older nodes call it --public-intents / public_intents, and current ones still accept that spelling), and for a node behind a reverse proxy — which is every hosted one — the proxy has to exempt the same path, since it is the layer actually enforcing auth. Hosted TEE fleet nodes are configured that way, from a single setting that drives both. A self-hosted node is not, by default: a member with their own node authors their own writes and does not need any of this.

So a 401/403 from the proxy rather than an IntentRefusedError from the node means you are pointed at a node that is not serving as a relay — not that your warrant is wrong.

Give the client a sealed fetch and the warrant and arguments reach only the relay’s attested TD:

import {
RelayClient,
createAttestedSealedFetch,
createQuoteVerifier,
trustedMeasurementsFromReleases,
} from '@calimero-network/mero-js';
// The node release's published-mrtds.json, shipped with the app.
import release from './trusted/mero-tee-v2.3.78.published-mrtds.json';
import { verify as dcapVerify } from '@phala/dcap-qvl';
const relay = new RelayClient({
relayUrl,
authorAccount,
authorProof,
deviceSecret,
fetch: createAttestedSealedFetch({
baseUrl: relayUrl,
verify: createQuoteVerifier({
dcapVerify,
...trustedMeasurementsFromReleases([release], { profile: 'locked-read-only' }),
}),
}),
});

createAttestedSealedFetch attests the relay (POST /admin-api/tee/attest, one of the calls that is never sealed), verifies the quote, and seals both describe and execute. connectCloud({ seal }) does the same for the relay it discovers. On a relay that leaves auth to a proxy, which every hosted one does, only describe/execute and the public probes can be sealed: a sealed request for anything else comes back 403 sealed_route_unguarded. See what can be sealed, and where.

Shipping published-mrtds.json pins the app to the releases it was built with. createSignedReleaseSealedFetch instead reads the release the relay runs from its GET /admin-api/tee/info, fetches that release’s file and bundle from the cloud’s mirror (https://cloud.calimero.network/api/tee/node-releases/{version}, answering {"data": {"version", "publishedMrtds", "bundle"}}), verifies its Sigstore signature in the client, and trusts that release’s measurements only:

import { RelayClient, createSignedReleaseSealedFetch } from '@calimero-network/mero-js';
import { verify as dcapVerify } from '@phala/dcap-qvl';
const relay = new RelayClient({
relayUrl,
authorAccount,
authorProof,
deviceSecret,
fetch: createSignedReleaseSealedFetch({
baseUrl: relayUrl,
dcapVerify,
profile: 'locked-read-only',
minReleaseVersion: '2.3.87',
}),
});

The relay is not trusted to say which image it runs. Its release must be signed by mero-tee’s node release workflow (checked against the Sigstore trust root embedded in mero-js), be no older than minReleaseVersion, and its quote must then match that release exactly. So the relay chooses only which signed release, at or above your floor, it presents. The floor is required because old releases stay validly signed forever. The mirror is not trusted either: releaseSource names another mirror’s base URL, or a function returning the release, such as a file bundled with the app, and whatever it returns is verified the same way. See trusting a signed release at run time. Bundling the release file, as above, still works for offline use.