Writing without a node, end to end
Every piece of delegated execution works. Getting a first write through it touches four systems, and the order matters, so this is the whole path in one place.
Read this first: somebody needs a node
Section titled “Read this first: somebody needs a node”This does not let a person sign up with Google and use Calimero with nothing installed. Two of the steps below can only be done from a node that is a direct admin of the namespace:
- claiming the namespace in the cloud requires a merod-signed ownership proof, and merod refuses to issue one unless the node is a direct admin of that group;
- granting the relay authorship is a governance op an admin signs.
So the shape is: one person (or one machine — a desktop app, a CI runner, a server) owns the namespace and does the setup once. After that, any number of keyholders with no node at all — browser tabs, phones, agents, that person’s own other devices — write into it through the relay.
That is the feature. It is multi-device and thin-client access to a namespace somebody owns, not nodeless namespace creation.
The cast
Section titled “The cast”| Who | Holds | Does |
|---|---|---|
| Owner | a node, admin of the namespace | steps 1–3, 5–6 |
| Cloud (MDMA) | the account and the fleet directory | steps 2–4 |
| Relay (TEE fleet node) | the group key, the app, an account | steps 4, 7 |
| Keyholder | one ed25519 key, nothing else | step 7 |
The path
Section titled “The path”-
Owner creates the namespace and a context from their node.
const owner = createMeroJs({ baseUrl: 'http://localhost:2528' });await owner.authenticate({ username, password });const { namespaceId } = await owner.admin.createNamespace({ applicationId, name: 'my-app' });const { contextId } = await owner.admin.createContext({ applicationId, groupId: namespaceId }); -
Owner claims it in the cloud, proving ownership with a node-signed proof. The cloud has no other way to know the namespace exists — there is deliberately no “create namespace” button in the cloud UI.
const cloud = owner.cloud; // or new CloudClient({...})await cloud.signInWithGoogle(googleIdToken);const proof = await owner.admin.issueNamespaceOwnershipProof(namespaceId, {audience: 'mdma:enable-ha-namespace',subject: cloud.getSession().user.email, // must equal the cloud accountnonce: crypto.randomUUID(), // single-use, cloud-sideexpiresAtMs: Date.now() + 60_000,});await cloud.claimNamespace(namespaceId, proof);The proof is minted, presented and spent within a minute; the cloud rejects a replayed nonce for any user. Forward merod’s response object as-is — the cloud accepts core’s
signerPublicKey/signedPayloadcasing as well as snake_case, so there is no re-keying step. -
Owner enables HA on the namespace — the step that asks the fleet to host it. It carries no proof of its own: the claim already established ownership.
await cloud.enableNamespaceHa(namespaceId); -
The fleet assigns a machine, and it reports itself. Nothing for anyone to do — the sidecar on each attested node polls once a second, is assigned the namespace, joins it by TDX attestation, and reports the two facts the cloud cannot derive: the account it writes as, and the URL to reach it.
const relays = await cloud.getNamespaceRelays(namespaceId);// [{ peerId, relayUrl, executorAccount, status: 'active', authorshipReady: false }]// or, across every namespace this account owns:const machines = await cloud.getMyMachines();// [{ peerId, relayUrl, executorAccount, canExecute: false, namespaces: [...] }]authorshipReadyshould already betrueon a namespace created by a recent merod — see step 5. If it isfalse, step 5 is the fix. -
Grant the relay authorship — usually already done.
A namespace is created carrying
CAN_AUTHOR_ON_BEHALFin its default-capability mask, and core copies that mask into a non-admin member’s capability row at admission. So a relay assigned to a namespace created by a merod with that default lands able to relay, and there is nothing to do here.You still land on this step in three cases:
- the namespace predates that default;
- the relay was admitted before the mask was set on it — the mask is copied at admission, not read live;
- the node is the owner’s own, which never receives the default at all: core seeds it for non-admin roles only, and the capability is not implied by admin.
Terminal window meroctl --node node1 group members set-capabilities <NAMESPACE_ID> <EXECUTOR_ACCOUNT> \--can-author-on-behalf// or from the SDK, on the owner's nodeawait owner.admin.grantAuthorship(namespaceId, executorAccount);CAN_AUTHOR_ON_BEHALFis implied by nothing — not membership, not admin, and it is not propagated by the subgroup cascade. The namespace default is the only thing that grants it without an explicit op, and aRestrictedsubgroup does not inherit it.grantAuthorshipreads the relay’s current mask and adds the one bit, because both the CLI flag form andsetMemberCapabilitiesreplace the mask rather than merging into it — so doing this by hand means re-passing every flag the relay already holds, and forgetting one revokes it silently. It is a no-op when the relay already has the grant, so it is safe to run on every startup.Within a second the relay notices and re-reports, and
getNamespaceRelaysflips toauthorshipReady: true. -
Owner adds the keyholder as a member, by account.
The keyholder generates its own identity offline first — no node, no network:
import {generateAccountRoot,mintDeviceId,signDeviceCert,} from '@calimero-network/mero-js';// The account root: kept offline, and the only thing that can certify a// device into this account. `phrase` is 24 words — show it once and have the// holder write it down, because nothing else can restore this account.// A keyholder that already has a root (a desktop app, a hardware key) skips// this and uses `accountForRoot(rootSecret)` instead. See// [Account roots](/reference/account-roots/).const { secret: rootSecret, accountId: account, phrase } = await generateAccountRoot();const device = await mintDeviceId(account, crypto.getRandomValues(new Uint8Array(16)));const authorProof = await signDeviceCert({rootSecret,device,signPublicKey, // the device key that will sign warrantskemPublicKey, // X25519, where a group key would be delivereddeviceEpoch: 1,});authorProofis the hexAccountProof<DeviceCert>that step 7 passes.The owner then adds that account, not a key:
await owner.admin.addGroupMembers(namespaceId, {members: [{ identity: account, role: 'Member' }],});By account because the keyholder’s device joins nothing and will never appear in any group’s binding rows — which is exactly the case a device certificate covers. A key-based membership check would refuse every write below.
-
The keyholder writes. No node, no group key, no credential on the relay.
import { connectCloud } from '@calimero-network/mero-js';const connection = await connectCloud({googleIdToken, // or a stored sessionTokenauthorAccount: account, // from step 6authorProof, // the device certificate from step 6deviceSecret, // the DEVICE signing secret — never transmitted});await connection.execute(contextId, 'set', { key: 'greeting', value: 'hello' });
Where it breaks, and what each failure means
Section titled “Where it breaks, and what each failure means”Every one of these is a different action, which is why the SDK reports them apart rather than as “could not connect”.
| Symptom | Cause | Fix |
|---|---|---|
connectCloud: “owns no namespaces” |
step 2 not done | claim the namespace from the owner’s node |
connectCloud: “owns N namespaces — pass namespaceId” |
ambiguous | name it |
connectCloud: “no relays: enable HA” |
step 3 not done | enable HA in the cloud |
connectCloud: “no relay has been assigned yet” |
step 4 in flight | wait; if it persists, no attested node has capacity or the MRTD is not allowlisted |
connectCloud: “waiting for the authorship grant” (names an account) |
step 5 not done | grant CAN_AUTHOR_ON_BEHALF to that account |
IntentRefusedError, “author’s account is not a member” |
step 6 not done | add the keyholder’s account to the group |
IntentRefusedError, retryable: true, “nonce” |
warrant replayed | re-present under a fresh warrant; if it repeats, the nonce source is restarting |
relay_url: null in the cloud |
the deployment publishes no relay URL | operator sets MDMA_TEE_RELAY_URL_TEMPLATE |
401/403 from the relay host, not an IntentRefusedError |
the node is not serving as a relay | the image needs delegated execution enabled at both merod and its ingress |
What the cloud stores, and how to read it back
Section titled “What the cloud stores, and how to read it back”Useful when debugging: these are the only rows that tie a person to a namespace and a machine.
| Row | Key | Written by |
|---|---|---|
UserNamespace |
(user_email, namespace_id), namespace_id globally unique |
the claim in step 2, after the ownership proof verifies. This is the authorization boundary for every namespace read |
HaRequest |
group_id unique |
enable-HA in step 3 |
FleetAssignment |
(peer_id, group_id) |
the relay’s own poll and confirm in step 4 — including authorship_ready |
Node |
name, plus peer_id |
the dispatcher at create time; executor_account and relay_url come from the relay’s poll |
Read them back with:
GET /api/cloud/me/namespaces— what this account owns, with HA state, its contexts, and replica counts.GET /api/cloud/me/namespaces/{ns}/relays— the relays for one namespace.GET /api/cloud/me/machines— every machine serving this account, grouped by machine, withcan_executefolding the three-way precondition.
Why the intent does not go through the cloud
Section titled “Why the intent does not go through the cloud”The cloud is a directory. It is handed an address and a public account id, and the write goes straight from the client to the relay.
A cloud that proxied intents would read the method and arguments of every write its users make — the plaintext is the whole point of the relay hop, since the relay has to run the method — and would sit on the critical path of every write in the fleet. What the client sends the cloud is “which relay serves me?”; what it sends the relay is the intent and a warrant it signed itself.