Give each part one responsibility
- The business contract enforces permissions, amounts, lifecycle transitions, and deadlines. It must remain safe when callers bypass the UI.
-
The host, currently
CamRoot, publishescamURIandcamHashand resolves contract namespace names to chain-specific addresses. Its owner controls that publication and those bindings. -
cam/main.jsondeclares the CAM version, entry route, namespaces, resources, and routes. Escrow uses"cam": "1.1.0"; Bike NFT uses"cam": "1.0.0". The current reader supports both. -
ABI resources describe contract calls. Contract namespaces point directly to
./abi/ContractName.json; the manifest pins those bytes with SHA-256 integrity. -
cam/ui.jsondeclares named UI nodes; current UI version is1.0.0. Its bytes are also pinned in the manifest. - An optional read projection is a read-only contract that gathers business state into screen values and available action names. It does not execute writes or authorize transactions; the business contract enforces permissions.
The host's manifest hash is Keccak-256 of the exact root manifest bytes. ABI/UI resource integrity uses SHA-256. Escrow document digests also use SHA-256, but are application commitments with a different purpose. Do not interchange these hashes.
Follow the actual escrow read route
This complete route excerpt comes from escrow's manifest. It reads the projection for the currently viewed account and hands the first output and a navigation selector to the UI's app node. In main.json, place this object at routes.createAgreementForm; it is not a standalone manifest.
{
"kind": "read",
"inputs": [],
"call": {
"namespace": "contracts.CamEscrowUI",
"function": "viewCreateAgreement",
"args": {
"client": "$account.address"
}
},
"then": {
"namespace": "ui",
"function": "app",
"args": {
"view": {
"screen": "$outputs.0",
"navigation": "navigation.create"
}
}
}
}
The contract namespace declares its ABI URI and integrity in namespaces; the host supplies its deployed address. Route argument names must match the ABI. $inputs refers to declared route inputs, $account.address to the account being viewed, and $outputs.0 to the first call output. These are protocol expressions, not JavaScript.
Keep calldata and payment together
CAM 1.1 permits native transaction value: the payment sent with a contract call. Escrow's creation route uses the same nested amount in the call arguments (calldata) and the payment (value). Place the object below at routes.createAgreement in main.json. After successful transaction confirmation, the browser viewer runs the follow-up route named in then, looking up the new agreement by client and reference.
{
"kind": "write",
"inputs": [
"params"
],
"value": "$inputs.params.amount",
"call": {
"namespace": "contracts.CamEscrow",
"function": "createAgreement",
"args": {
"params": "$inputs.params"
}
},
"then": {
"namespace": "routes",
"function": "lookupAgreement",
"args": {
"client": "$account.address",
"agreementRef": "$inputs.params.agreementRef"
}
},
"review": {
"call": {
"namespace": "contracts.CamEscrowUI",
"function": "arbitrationTimeoutBeneficiaryId",
"args": {
"beneficiary": "$inputs.params.arbitrationTimeoutBeneficiary"
}
},
"then": {
"namespace": "ui",
"function": "escrow.create.review",
"args": {
"view": {
"params": "$inputs.params",
"timeoutRecipient": "$outputs.0.read.0"
}
}
}
}
}
Read routes continue to UI nodes. Write routes continue to another route. A prepared write is calldata and value for simulation and wallet review; it is not authorization or proof of execution. The business contract must reject invalid calls even if a UI advertises them.
In the browser viewer, connect the wallet before preparing creation. Connecting selects the wallet address as $account.address and discards earlier prepared calls. The successful creation transaction makes its sender the client, so this follow-up lookup uses that client's address and the entered reference. If the wallet reports an account change, the viewer refreshes automatically: wait for that view, check the account and chain, then prepare again. Providers without change notifications require manual reconnection through Switch wallet. An app using the headless library must coordinate its viewed account with its signer and run the follow-up only after successful confirmation.
Compose a declarative UI
Named nodes declare requires: ["view"] when they receive a view argument. Include calls UI nodes, Button calls routes, and fields bind local state.key values that expressions can read through $state. Escrow's app node selects a named screen using $view.screen.viewId.
| Elements | Use |
|---|---|
| Screen, Fragment | A titled screen or a group of children. |
| Text, Address, Status, Nft | Display text, addresses, values, and NFT references. Address displays an address; it is not an input field. |
| TextField | Collect text, including document URIs. |
| DocumentHashField | Collect an exact-byte SHA-256 digest manually, from a local file, or by explicitly reading a declared HTTP(S) source URI. |
| AmountField | Collect an exact base-unit amount with native/display-unit conversion, using a declared unit. |
| NonNegativeIntegerField | Collect whole nonnegative decimal integers with optional min/max bounds; escrow sets a minimum of 1 for durations. |
| Include, Button | Compose named UI nodes or dispatch declared routes. |
This complete anonymous field excerpt is from escrow's UI; place it among a node's children:
{
"element": "AmountField",
"props": {
"label": "Escrow amount",
"unit": {
"assetId": "native", "baseLabel": "wei",
"displayLabel": "Native asset", "decimals": 18
}
},
"state": { "key": "amount", "defaultValue": "" }
}
The amount field stores an exact base-unit decimal string in $state.amount. The creation button below copies that value into params.amount, along with the other fields required by the contract. The write route above then reads it as $inputs.params.amount for both calldata and payment. Place this complete named node at nodes.createAgreement in ui.json:
{
"requires": ["view"],
"element": "Button",
"props": { "label": "Create agreement" },
"call": {
"namespace": "routes",
"function": "createAgreement",
"args": {
"params": {
"agreementRef": "$state.agreementRef",
"contractor": "$state.contractor",
"arbitrator": "$state.arbitrator",
"arbitrationTimeoutBeneficiary": "$state.arbitrationTimeoutBeneficiary",
"amount": "$state.amount",
"acceptanceDuration": "$state.acceptanceDuration",
"workDuration": "$state.workDuration",
"reviewDuration": "$state.reviewDuration",
"arbitrationDuration": "$state.arbitrationDuration",
"terms": {
"uri": "$state.termsUri",
"sha256Digest": "$state.termsDigest"
}
}
}
}
}
The checked-in creation screen includes the nodes named in $view.actions using the object below among nodes["escrow.create"].children. For a nonzero viewed client address, the creation view supplies createAgreement as one of those names. requires: ["view"] declares that the named button receives the read route's view argument; the Include passes it through. This does not create the form fields or authorize a transaction. Keep the other creation fields from the existing screen: the amount field and button alone do not supply a complete agreement.
{
"element": "Include",
"call": {
"namespace": "ui",
"function": "$view.actions",
"args": { "view": "$view" }
}
}
Escrow keeps document URIs in UriField and digest values in DocumentHashField. This complete terms field belongs among nodes["escrow.create"].children in ui.json:
{
"element": "DocumentHashField",
"props": {
"label": "Terms SHA-256 digest",
"algorithm": "sha256",
"sourceURI": "$state.termsUri"
},
"state": {
"key": "termsDigest",
"defaultValue": "0x0000000000000000000000000000000000000000000000000000000000000000"
}
}
label and the literal algorithm: "sha256" are required. The optional sourceURI expression supplies the URL for an explicit Calculate from URL action; it does not change the URI field. The scalar state.key holds the digest, so the creation button above still passes $state.termsDigest into params.terms.sha256Digest. Replace the all-zero default before creating an agreement.
The browser offers manual entry and local file calculation without uploading. URL calculation accepts HTTP(S), requires readable browser CORS access, omits credentials and referrers, and rejects redirects. Calculation is limited to 8 MiB and a 15-second deadline. Failures and cancellation preserve the digest; changing the URI preserves it but removes the prior calculation's association and invalidates pending work. The viewer does not fetch while rendering or typing and does not automatically fall back to another method.
Calculation hashes exact bytes, not URI text or normalized JSON. It does not compare against an existing expected digest. A local file hash does not check the URI. A URL hash describes the bytes returned at calculation time; later responses may differ. IPFS retrieval and a server proxy are not provided by this helper. See the document guide for user steps and contract limits.
Use enabledWhen against contract-provided action IDs to disable unavailable actions. This improves the rendered workflow but does not replace onchain enforcement.
Validate the bundle before publishing
Keep Solidity under dapps/<name>/src/ and tests under test/. Deliberately create cam/abi/ before ABI export. This guide explains the existing bundles; it is not a one-command scaffold for a new dapp. A new dapp also needs explicit ABI outputs admitted in compose/forge-abi.yml and a manifest output admitted in compose/cam.yml. Follow their existing mount patterns and the rendered-config checks under tests/checks; do not grant writes over the repository root.
make dapps/deps/install
make js/deps/install
make cam/abi/export
make cam/conformance/check
make tests/checks
make cam/abi/export exports manifest-declared ABIs and refreshes local integrity pins. For UI-only resource edits, make cam/integrity/refresh refreshes pins without ABI export. Inspect generated changes. When starting from the escrow bundle, run make dapps/tests/unit-scenario for repository checks and Forge unit and scenario tests, make js/tests for the TypeScript workspace, and make escrow/local/scenario/run for its nine local settlement paths and withdrawal. New dapps need checks for their own contract and workflow behavior. Conformance validates deterministic publication joins and resource integrity; it does not prove live authorization, RPC reliability, wallet behavior, or security.
The existing Bike NFT release guide documents its separate release commands and role handoffs. For the existing escrow app, make escrow/release/deploy plans and deploys a release only with its required explicit confirmation, chain, publication, owner, secret-file, and output settings; make escrow/release/verify verifies that recorded release. Use the complete commands and prerequisite publication steps in escrow's release guide and the shared release ceremony. They own root-hash calculation and address binding; replace no required settings with guesses. New dapps need their own deliberate deployment wiring. Viewers validate contract-returned manifests, resources, URLs, and actions as untrusted input.
Open and inspect a workflow
The browser startup query accepts chainId, host, account, and rpcUrl, all required. Run make escrow/local/demo/gui/up or make bike-nft/local/demo/gui/up to deploy a fresh disposable fixture, start its viewer, and print its complete browser URL; the escrow and Bike NFT guides explain setup and wallet use. Read-route navigation uses the fragment, for example #route=agreement&agreementId=0x… (replace the abbreviated ID with a complete agreement ID). A fragment opens a read view; it cannot prepare or send a write. Structured inputs can use a JSON inputs fragment; numeric ABI inputs must be decimal strings rather than JSON numbers.
The account query selects the browser's initial read context. Connecting a wallet replaces that context with the wallet address and clears any prepared call. After connection, wallets that report account and network changes update the viewer automatically and clear unsent prepared calls. After an account change, wait for the refreshed view, check the account and chain, then prepare anew. A different wallet network leaves the viewer's configured chain and RPC unchanged and disables sending until the wallet returns to that chain. Disconnecting keeps the last successful read-only view; submitted hashes and receipt tracking survive wallet changes. Providers without change notifications require Switch wallet to reconnect after changing accounts or networks. Resource-origin and unsigned-manifest policy are viewer configuration, not extra URL parameters. URLs and fragments are visible: do not put secrets in them.
Compare Bike NFT's bundle with escrow's bundle, then follow each package README for its local fixture and release workflow.