This is the full protocol specification. For a shorter path, use one of theSDKs: they handle the signature and the document format for you.

Mosaicdeck Metrics Protocol v0.1

Status: Draft, stable enough to build against Audience: Developers who want their app’s own numbers (signups, orders, content, jobs, health) on their Mosaicdeck dashboard and in their AI assistant.

Renamed from the working name “SiteDeck” (docs/DECISIONS.md D010). The algorithm is unchanged; the original SiteDeck test vector (secret sds_test_7f3a…a6e0, path /.well-known/sitedeck → 710917d1…63d2) still verifies (it only depends on the secret and path you feed it), and §3.1 gives the vector for the Mosaicdeck strings.

The conformance words MUST, SHOULD and MAY follow RFC 2119.


1. What it is

Your app exposes one HTTPS endpoint that returns a small JSON document describing its current numbers. Mosaicdeck polls it on a schedule and stores the results over time. It then builds dashboards from the numbers, and makes them available to your AI assistant over MCP.

The document is self-describing. Each metric says what it is: its type, unit, label, and whether higher is better. Mosaicdeck uses that to pick the right chart and build a sensible dashboard without anyone configuring it.

  • Read-only. Mosaicdeck only ever sends GET requests. It never writes to your app.
  • Pull, not push. You don’t need queues, SDK background threads or outbound network access.
  • Language-agnostic. Around 30 lines in any language (see §8).

2. The endpoint

Default URL https://<your-site>/.well-known/mosaicdeck
Custom path Allowed. It’s set per site in the Mosaicdeck dashboard, and MUST be on the same host as the site or a subdomain of it.
Method GET (Mosaicdeck never sends anything else)
Scheme https only
Response type application/json; charset=utf-8
Max response size 256 KB. Larger responses are rejected.
Timeout Mosaicdeck waits at most 10 s. Endpoints SHOULD respond in under 2 s.
Redirects Not followed. Serve the document directly from the configured URL.
Caching You MAY cache the computed document for up to 60 s.

Poll frequency depends on the Mosaicdeck plan: every 60 minutes on Free, 15 minutes on Pro and 5 minutes on Agency. Values SHOULD be cheap to compute, such as indexed COUNT(*) queries or counters you already keep.


3. Authentication

Every request from Mosaicdeck is signed with the site secret shown once when the site is connected. It’s a string that starts with mds_ (production secrets are mds_ followed by 43 base64url characters, i.e. 32 random bytes). Treat it as an opaque string and store it like any other API secret, for example as the env var MOSAICDECK_SECRET.

Mosaicdeck sends:

Mosaicdeck-Signature: t=<unix-seconds>,v1=<hex-hmac>
Mosaicdeck-Site: <site public id, e.g. site_01J9X...>
User-Agent: Mosaicdeck-Poller/0.1 (+https://<mosaicdeck-domain>/docs/protocol)

Where:

signed_string = "<t>.<METHOD>.<path-with-query>"      e.g. "1759000000.GET./.well-known/mosaicdeck"
v1            = lowercase_hex( HMAC_SHA256( key = site_secret, message = signed_string ) )
  • <path-with-query> is the configured endpoint URL’s path plus query string, serialised by a standard WHATWG URL parser (percent-encoding normalised, no fragment). For example: /.well-known/mosaicdeck or /api/mosaicdeck?x=1. Mosaicdeck shows this exact string on the site’s Connections tab.
  • The key is the secret string’s UTF-8 bytes, including the mds_ prefix.
  • Behind a proxy, rewrite, basePath or API gateway, the path your app sees may differ from what Mosaicdeck signed. So verifiers SHOULD compute the signature over a configured constant, MOSAICDECK_PATH, which defaults to /.well-known/mosaicdeck and must equal the string shown in the dashboard. They should not use the observed request path. All examples in §8 do this. If the configured URL has a query string, MOSAICDECK_PATH includes it, but your framework’s route should match only the path part.
  • Trailing slashes: some frameworks redirect /x to /x/. Mosaicdeck doesn’t follow redirects, so configure the URL exactly as your app serves it.

Your endpoint MUST:

  1. Parse t and v1 from the header. If either is missing or malformed (v1 isn’t 64 lowercase hex characters), respond 401.
  2. Reject the request if |now − t| > 300 seconds (replay window). Respond 401.
  3. Recompute v1 and compare it with a constant-time comparison. If they don’t match, respond 401. Malformed input MUST produce 401, never a 500.
  4. Serve the document only after those checks pass.

Test vector: secret mds_test_7f3a9c2e41b84d06a5e1c9f2d8b3a6e0, t = 1759000000, method GET, path /.well-known/mosaicdeck

signed_string = 1759000000.GET./.well-known/mosaicdeck
v1            = 30d59a7de9b04814b3fd2b949b213063388bdf62c6d9c71533a67138c5090c4a

3.2 Bearer token (fallback)

Some platforms, like no-code tools or static-function hosts, can’t compute an HMAC easily. For those, a site MAY be set to bearer mode in Mosaicdeck. Mosaicdeck then sends:

Authorization: Bearer <site_secret>

The endpoint compares the token with a constant-time comparison. Bearer mode is weaker, because the secret travels with every request. The dashboard labels it that way.

3.3 Rotating the secret

The dashboard’s “Rotate secret” issues a new secret. For 24 hours Mosaicdeck signs with the new secret, and if that request gets 401 it retries once with the old one. This lets you deploy the new secret without downtime. After 24 hours the old secret is discarded.


4. Response document

{
  "protocol": "mosaicdeck/0.1",
  "generated_at": "2026-09-25T17:00:00Z",
  "site": {
    "name": "Grandma's Recipes",
    "kind": "recipes",
    "description": "Family recipe collection with a meal planner",
    "version": "1.4.2"
  },
  "metrics": [
    { "key": "users_total", "label": "Registered users", "type": "counter", "value": 1284,
      "unit": "users", "group": "Users", "primary": true },
    { "key": "recipes_saved_total", "label": "Recipes saved", "type": "counter", "value": 9031,
      "unit": "saves", "group": "Engagement" },
    { "key": "recipes_published", "label": "Published recipes", "type": "gauge", "value": 412,
      "unit": "recipes", "group": "Content" },
    { "key": "revenue_total_usd", "label": "Tip jar revenue", "type": "counter", "value": 1520.5,
      "format": "currency:USD", "group": "Revenue" },
    { "key": "p95_response_ms", "label": "API p95 latency", "type": "gauge", "value": 182,
      "format": "duration_ms", "higher_is_better": false, "group": "Health" },
    { "key": "database", "label": "Database", "type": "status", "value": "ok",
      "message": "Connected, 12 ms", "group": "Health" },
    { "key": "email_queue", "label": "Email queue", "type": "status", "value": "warn",
      "message": "412 emails pending", "group": "Health" },
    { "key": "popular_recipes", "label": "Most viewed recipes", "type": "list", "window": "7d",
      "unit": "views", "group": "Content",
      "value": [
        { "label": "Lasagna", "value": 312, "url": "/r/lasagna" },
        { "label": "Chicken soup", "value": 201, "url": "/r/chicken-soup" }
      ] }
  ]
}

4.1 Top-level fields

Field Required Rules
protocol MUST "mosaicdeck/0.<minor>". Mosaicdeck accepts any 0.x.
generated_at SHOULD ISO 8601 UTC timestamp. Mosaicdeck uses its own receive time for storage and this one only for display.
site SHOULD An object, described below.
metrics MUST An array of 0–50 metric objects.

site

Field Rules
name ≤ 80 chars. Used as the default site name in Mosaicdeck.
kind One of: recipes, music, game, blog, news, ecommerce, saas, portfolio, community, docs, events, education, nonprofit, other. It helps Mosaicdeck choose dashboard layouts. Unknown values are treated as other.
description ≤ 200 chars.
version ≤ 40 chars. Your app version, shown next to the data.

4.2 Metric fields (all types)

Field Required Rules
key MUST Matches ^[a-z][a-z0-9_]{0,63}$. Unique within the document. Stable over time, since history is stored by key.
type MUST counter, gauge, status or list (§5).
value MUST Its shape depends on type.
label SHOULD ≤ 60 chars, human readable. Defaults to the key with _ replaced by spaces.
description MAY ≤ 200 chars. Shown in tooltips and given to the AI.
unit MAY ≤ 20 chars, a plural noun such as users, orders, views.
format MAY number (default), percent (value 0–100), duration_ms, bytes, or currency:<ISO 4217> such as currency:USD.
higher_is_better MAY Boolean, default true. It controls whether an increase is shown green or red.
group MAY ≤ 30 chars. Metrics with the same group are placed together on the dashboard.
primary MAY Boolean. Marks a headline metric shown on the portfolio overview card. At most 2 per document; any beyond that are ignored.

Unknown fields MUST be ignored by Mosaicdeck. That’s how minor versions add fields without breaking anything.


5. Metric types

5.1 counter: a running total

value is a finite number ≥ 0 that only goes up, such as total users, total orders or total revenue.

  • Report the all-time total, not “today’s count”. Mosaicdeck works out change between polls itself, so a missed poll never loses data and you never have to track time windows.
  • Mosaicdeck stores the delta value − previous_value for each poll interval. If the value decreases, Mosaicdeck treats it as a reset (a restart, or a counter kept in memory) and records the delta as value.
  • Dashboards show counters as “per day” or “per week” rates, plus the running total.

5.2 gauge: a current reading

value is any finite number describing right now, such as published recipes, active sessions, queue depth or latency. Mosaicdeck stores each poll’s value and charts it over time.

5.3 status: a health signal

value is "ok", "warn" or "down". An optional message (≤ 200 chars) explains it.

  • A change to down, and staying warn for 3 or more consecutive polls, can trigger alerts (the user configures this in Mosaicdeck).
  • Use it for dependencies such as the database, payment provider, job queue, backups or disk space.

5.4 list: a ranked top-N

value is an array of up to 20 items: { "label": string ≤ 100, "value": number, "url"?: string ≤ 300 }, sorted by value in descending order.

  • window (MAY): the period the list covers, e.g. "24h", "7d", "30d" or "all". It defaults to "all".
  • Mosaicdeck keeps the latest list and one snapshot per day, so users can ask “what was popular last week?”.
  • url MAY be relative to the site.

6. Privacy rules (MUST)

  • No personal data. Don’t put email addresses, names of your users, IP addresses or other identifiers in any field, including list labels. “Most active users” lists MUST use pseudonyms or IDs that can’t be linked to a person.
  • Report aggregates, not records.
  • Mosaicdeck stores what you send for the retention period of the user’s plan, and deletes it when the site is removed.

7. How Mosaicdeck handles errors

Situation What Mosaicdeck does
Network error, timeout, non-2xx response, or a response over 256 KB Marks the poll as failed. After 3 consecutive failures, the site shows “Metrics endpoint unreachable”, which is separate from site uptime. The user can opt in to an alert for this.
401 / 403 Shown as “Authentication failed: check MOSAICDECK_SECRET”. Polling backs off to once per hour until a poll succeeds or the secret is rotated.
Response isn’t valid JSON, or protocol is missing The poll fails, with the parse error shown in the dashboard.
Some individual metrics are invalid Only those metrics are skipped. The rest are stored. Each skipped metric appears in the site’s “Endpoint health” panel with the reason, e.g. recipes_total: counter value must be ≥ 0.
A key disappears Its history is kept. It’s marked “not reported since ”.
A new key appears It’s stored at once, and Mosaicdeck suggests adding it to the dashboard. It never changes a dashboard the user has edited without asking.
A key changes type The new type wins from that poll onward. History under the old type is kept and labelled.

8. Reference implementations

8.1 Node.js (any framework): signature check

import crypto from "node:crypto";

const SECRET = process.env.MOSAICDECK_SECRET;
const PATH = process.env.MOSAICDECK_PATH || "/.well-known/mosaicdeck"; // must match the path shown in Mosaicdeck

/** Returns true only for a fresh, correctly signed request. Never throws. */
export function verifyMosaicdeck(signatureHeader, { secret = SECRET, path = PATH, now = Date.now() } = {}) {
  const parts = Object.fromEntries(
    String(signatureHeader || "").split(",").map((kv) => kv.trim().split("=", 2))
  );
  if (!/^\d{1,12}$/.test(parts.t || "") || !/^[0-9a-f]{64}$/.test(parts.v1 || "")) return false;
  if (Math.abs(now / 1000 - Number(parts.t)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.GET.${path}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

// Express (route on the path part only; PATH may include a query string):
// app.get(PATH.split("?")[0], (req, res) => verifyMosaicdeck(req.get("Mosaicdeck-Signature")) ? res.json(doc()) : res.sendStatus(401));

8.2 Next.js (App Router): app/.well-known/mosaicdeck/route.ts

import crypto from "node:crypto";
import { db } from "@/lib/db";

const PATH = process.env.MOSAICDECK_PATH ?? "/.well-known/mosaicdeck";

function verify(header: string | null): boolean {
  const p = Object.fromEntries((header ?? "").split(",").map((kv) => kv.trim().split("=", 2)));
  if (!/^\d{1,12}$/.test(p.t ?? "") || !/^[0-9a-f]{64}$/.test(p.v1 ?? "")) return false;
  if (Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;
  const expected = crypto.createHmac("sha256", process.env.MOSAICDECK_SECRET!)
    .update(`${p.t}.GET.${PATH}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1));
}

export async function GET(req: Request) {
  if (!verify(req.headers.get("mosaicdeck-signature"))) return new Response("Unauthorized", { status: 401 });

  return Response.json({
    protocol: "mosaicdeck/0.1",
    generated_at: new Date().toISOString(),
    site: { name: "Grandma's Recipes", kind: "recipes" },
    metrics: [
      { key: "users_total", label: "Registered users", type: "counter", value: await db.user.count(), primary: true },
      { key: "recipes_published", label: "Published recipes", type: "gauge", value: await db.recipe.count({ where: { published: true } }) },
      { key: "database", label: "Database", type: "status", value: "ok" },
    ],
  });
}

8.3 Python (Flask; Django is analogous)

import hmac, hashlib, os, re, time
from flask import Flask, request, jsonify, abort

app = Flask(__name__)
SECRET = os.environ["MOSAICDECK_SECRET"].encode()
PATH = os.environ.get("MOSAICDECK_PATH", "/.well-known/mosaicdeck")  # must match the path shown in Mosaicdeck
HEX64 = re.compile(r"^[0-9a-f]{64}$")

def verify(header: str) -> bool:
    parts = dict(kv.strip().split("=", 1) for kv in (header or "").split(",") if "=" in kv)
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or not HEX64.match(v1):
        return False
    if abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(SECRET, f"{t}.GET.{PATH}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

@app.get(PATH.split("?")[0])  # route on the path part; PATH may include a query string
def mosaicdeck():
    if not verify(request.headers.get("Mosaicdeck-Signature", "")):
        abort(401)
    return jsonify(protocol="mosaicdeck/0.1", site={"name": "My Band", "kind": "music"}, metrics=[
        {"key": "newsletter_subscribers", "type": "counter", "value": 842, "unit": "subscribers", "primary": True},
        {"key": "upcoming_shows", "type": "gauge", "value": 3, "unit": "shows"},
    ])

8.4 Any language

  1. Read the Mosaicdeck-Signature header and split it on , into t and v1. If t isn’t all digits or v1 isn’t 64 lowercase hex characters, return 401.
  2. If |now − t| > 300, return 401.
  3. Compute hex(HMAC_SHA256(secret, t + ".GET." + MOSAICDECK_PATH)), where MOSAICDECK_PATH is the path-with-query shown in Mosaicdeck.
  4. If it isn’t equal to v1 (using a constant-time comparison), return 401.
  5. Return the JSON document from §4.

SDK packages (@mosaicdeck/node, mosaicdeck on PyPI) wrap steps 1–4 and add a metric builder. They are conveniences; the protocol is what counts.


9. JSON Schema

Mosaicdeck validates responses against this schema, one metric at a time (§7), and is lenient at the top level:

  • a missing or invalid site object is ignored, with a warning
  • beyond 50 metrics, only the first 50 are kept, with a warning
  • only a missing or invalid protocol, or a metrics value that isn’t an array, fails the whole poll
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://{{DOMAIN}}/schema/protocol-0.1.json",
  "type": "object",
  "required": ["protocol", "metrics"],
  "properties": {
    "protocol": { "type": "string", "pattern": "^mosaicdeck/0\\.[0-9]+$" },
    "generated_at": { "type": "string", "format": "date-time" },
    "site": {
      "type": "object",
      "properties": {
        "name": { "type": "string", "maxLength": 80 },
        "kind": { "type": "string" },
        "description": { "type": "string", "maxLength": 200 },
        "version": { "type": "string", "maxLength": 40 }
      }
    },
    "metrics": { "type": "array", "maxItems": 50, "items": { "$ref": "#/$defs/metric" } }
  },
  "$defs": {
    "base": {
      "type": "object",
      "required": ["key", "type", "value"],
      "properties": {
        "key": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" },
        "label": { "type": "string", "maxLength": 60 },
        "description": { "type": "string", "maxLength": 200 },
        "unit": { "type": "string", "maxLength": 20 },
        "format": { "type": "string", "pattern": "^(number|percent|duration_ms|bytes|currency:[A-Z]{3})$" },
        "higher_is_better": { "type": "boolean" },
        "group": { "type": "string", "maxLength": 30 },
        "primary": { "type": "boolean" }
      }
    },
    "metric": {
      "allOf": [{ "$ref": "#/$defs/base" }],
      "oneOf": [
        { "properties": { "type": { "const": "counter" }, "value": { "type": "number", "minimum": 0 } } },
        { "properties": { "type": { "const": "gauge" }, "value": { "type": "number" } } },
        { "properties": { "type": { "const": "status" }, "value": { "enum": ["ok", "warn", "down"] },
                          "message": { "type": "string", "maxLength": 200 } } },
        { "properties": { "type": { "const": "list" }, "window": { "type": "string", "maxLength": 10 },
                          "value": { "type": "array", "maxItems": 20, "items": {
                            "type": "object", "required": ["label", "value"],
                            "properties": { "label": { "type": "string", "maxLength": 100 },
                                            "value": { "type": "number" },
                                            "url": { "type": "string", "maxLength": 300 } } } } } }
      ]
    }
  }
}

10. Versioning

  • 0.x minor versions only add optional fields or new kind values. Existing endpoints keep working unchanged.
  • A breaking change would be mosaicdeck/1.0. Mosaicdeck would keep accepting 0.x for at least 12 months after 1.0 ships.