Developer console

Quickstart

Your server registers a signature request (an intent). The end user confirms it with a passkey on the Connect screen and returns to your URL. The signature is complete when your server reads completed in status.state.

Developer console

The organisation (Org), app, host and private key you need before registering an intent.

  1. 1. Create an organisation.

  2. 2. Open the Org and subscribe to Signature in its plan (the plan view is /developers/{orgId}).

  3. 3. Register an app at /developers/{orgId}/apps/new. Its host is one label under the Org's apex domain, such as app.example.com.

  4. 4. Add the TXT record shown on the app page to your DNS exactly as it appears.

  5. 5. On the same page, press Confirm host.

  6. 6. Under the app's declared use, say who uses it and which action the signature covers, then save.

  7. 7. Put the operate private key on your server.

When intents go through

An app can register signature intents only when its Org meets all of the following.

  • The account holder's passkey is in place (the Org's plan view).
  • The Org subscribes to Signature.
  • The app's host is confirmed (its status on the app page reads Active).
  • The app's declared use includes signatures.
  • This month's limit has not been reached (see the remaining amount in the plan view).

CLI and agents

You can run the same setup through the @clavis.nexus/setup CLI or its MCP server. Passkeys, card payment and DNS records stay with a person; watch for needs_human in tool responses.

  1. 1. Run npm install -g @clavis.nexus/setup, then call clavis (or use npx -y @clavis.nexus/setup).

  2. 2. Run clavis login and confirm with your passkey in the browser.

  3. 3. Run clavis status to see which steps are missing.

  4. 4. Fill the missing steps in this order: org-anchor-*, card-checkout, app-register, app-host-*, declaration-set.

  5. 5. The MCP server is clavis mcp. When everything is in place, clavis emit-env prints the environment variables for intents.

Steps

Register on your server, open Connect in the browser, and read completed after the user returns.

  1. 1. Install npm install @clavis.nexus/app on the server that runs on your confirmed host.

  2. 2. Install npm install @clavis.nexus/app-shell in the browser code.

  3. 3. Register on the server and return JSON to the browser.

    digest (your app)
    function canonicalStringify(value: unknown): string {
      if (value === undefined) throw new Error("canonical_json_undefined");
      if (typeof value === "number" && !Number.isFinite(value)) throw new Error("canonical_json_non_finite");
      if (value === null || typeof value !== "object") return JSON.stringify(value);
      if (Array.isArray(value)) return `[${value.map((item) => canonicalStringify(item)).join(",")}]`;
      const record = value as Record<string, unknown>;
      const keys = Object.keys(record).sort();
      return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalStringify(record[key])}`).join(",")}}`;
    }
    
    async function sha256Base64Url(text: string): Promise<string> {
      const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
      let bin = "";
      for (const byte of digest) bin += String.fromCharCode(byte);
      return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
    }
    
    const messageDigest = await sha256Base64Url(canonicalStringify({ v: 1, action: "example" }));
    TypeScript (server)
    import { createAppClient } from "@clavis.nexus/app";
    
    function requiredEnv(name: string): string {
      const value = process.env[name];
      if (!value) throw new Error(`missing env: ${name}`);
      return value;
    }
    
    const app = createAppClient({
      apiOrigin: "https://api.clavis.nexus",
      appId: requiredEnv("CLAVIS_APP_ID"),
      operatePrivateJwk: requiredEnv("OPERATE_PRIVATE_JWK"),
      masterPublicJwkJson: requiredEnv("MASTER_PUBLIC_JWK_JSON"),
    });
    
    const { requestId, pollSecret, consumeUrl } = await app.registerGenericSign({
      messageDigest,
      purpose: "generic.sign",
      returnUrl: "https://console.example/done",
    });
    
    await store.set(requestId, { pollSecret, messageDigest });
    return Response.json({ consume_url: consumeUrl, request_id: requestId });
    • CLAVIS_APP_ID
      The app's id.
    • OPERATE_PRIVATE_JWK
      The Ed25519 private JWK as JSON. Only your server holds it.
    • MASTER_PUBLIC_JWK_JSON
      Open https://api.clavis.nexus/.well-known/jwks.json in a browser and store its master object as a JSON string.
    • messageDigest
      The digest this signature fixes.
    • returnUrl
      An https URL under your confirmed host.
    • store
      A session or KV that the return request can read, keyed by request_id.

    JSON your server returns to the browser.

    {
      "consume_url": "https://api.clavis.nexus/app/intent-requests/consume/550e8400-e29b-41d4-a716-446655440000",
      "request_id": "550e8400-e29b-41d4-a716-446655440000"
    }
    • consume_urlURLrequired
      The browser opens it, which enters Connect.
    • request_idUUIDrequired
      The key your server uses to read poll_secret after the return.

    Keep poll_secret in the server store only. Do not put it in this JSON.

  4. 4. The browser POSTs to your server on a path your app chooses, then opens the returned consume_url.

    TypeScript (browser)
    import { openHandoffShell } from "@clavis.nexus/app-shell";
    import "@clavis.nexus/app-shell/styles.css";
    
    await openHandoffShell({
      allowedConsumeHosts: ["api.clavis.nexus"],
      async register() {
        const res = await fetch("/api/start", {
          method: "POST",
          credentials: "same-origin",
          headers: { Accept: "application/json" },
        });
        const body: unknown = await res.json();
        if (!res.ok || !body || typeof body !== "object" || !("consume_url" in body) || typeof body.consume_url !== "string") {
          const code =
            body && typeof body === "object" && "error" in body && typeof body.error === "string"
              ? body.error
              : `HTTP ${res.status}`;
          throw new Error(code);
        }
        return body.consume_url;
      },
    });
  5. 5. The end user confirms with a passkey on Connect.

  6. 6. The browser comes back to returnUrl with only request_id in the query. Your server reads the store with that key and checks that state from waitForCompletion is completed.

    TypeScript (completion)
    const requestId = new URL(request.url).searchParams.get("request_id");
    const stored = await store.get(requestId);
    const status = await app.waitForCompletion(requestId, stored.pollSecret, {
      expectedMessageDigest: stored.messageDigest,
    });
    // status.state === "completed"
    // Keep status.sub / status.messageDigest / status.purpose / status.signedAt in your product

    Example return URL.

    https://console.example/done?request_id=550e8400-e29b-41d4-a716-446655440000
  7. 7. Keep sub, messageDigest, purpose and signedAt from completed in your product. A sample of what to keep is in GET /status under Signatures; what to do after a timeout is in Waiting for completion. The shape of payload.sign_proof over HTTP is in Authentication.

Reference

Credentials and HTTP reference.

  • Authentication — Credentials for registering intents and calling the Org API.
  • Signatures — Reading register, status, waiting for completion, counts and errors.