Specification · 2026-08-01

The AdminModule bridge protocol

This page is written for AI coding agents. Implement one authenticated server-side endpoint, describe the application's business resources in a manifest, and send AdminModule a handshake. AdminModule renders the entire backoffice from that description and never touches the application's database.

  • · One endpoint, nine operations, one manifest.
  • · The secret lives only in server-side environment variables.
  • · All business logic stays inside the connected application.

1. Install

The founder creates a project in AdminModule and receives a PROJECT_ID and an ADMIN_MODULE_SECRET. Store them as server-side secrets in the connected application:

Required environment variables

ADMIN_MODULE_PROJECT_ID=<project id from AdminModule>
ADMIN_MODULE_SECRET=<secret shown once during installation>

# Never expose these to the browser. No VITE_ / NEXT_PUBLIC_ prefix.
# Never log them, never return them from a public endpoint.

2. Authentication

V1 uses a shared secret sent server-to-server in both directions. Compare it in constant time. The protocol version header is present so request signing (HMAC) can be added later without changing any request or response shape.

Verifying an AdminModule request

// AdminModule -> your app. Every bridge request carries these headers.
// x-adminmodule-project-id: <your AdminModule project id>
// x-adminmodule-secret:     <ADMIN_MODULE_SECRET>
// x-adminmodule-protocol:   2026-08-01
//
// Legacy: apps installed before the rename received x-adminkit-* headers and an
// ADMIN_KIT_SECRET. Both header families are sent and accepted, so existing
// integrations keep working unchanged. New installs should use the names above.

function authorize(request: Request): boolean {
  const secret =
    request.headers.get("x-adminmodule-secret") ?? request.headers.get("x-adminkit-secret");
  const expected = process.env["ADMIN_MODULE_SECRET"] ?? process.env["ADMIN_KIT_SECRET"]; // server-only, never VITE_*

  if (!secret || !expected || secret.length !== expected.length) return false;
  let diff = 0;
  for (let i = 0; i < secret.length; i++) diff |= secret.charCodeAt(i) ^ expected.charCodeAt(i);
  return diff === 0;
}

// Reject everything else:
// return new Response(JSON.stringify({ ok: false, error: "Unauthorized" }), { status: 401 })

3. Bridge endpoint

Expose exactly one HTTPS POST endpoint. It must be server-side and reachable from the public internet, because AdminModule calls it directly.

Endpoint skeleton

// ONE server-side endpoint. Example: TanStack Start server route.
// Any HTTPS POST endpoint works (Next route handler, Express, edge function...).
// src/routes/api/public/adminmodule-bridge.ts

type Operation =
  | "handshake" | "ping" | "manifest"                       // connection + discovery
  | "list" | "get" | "create" | "update" | "delete" | "action";  // data

interface BridgeRequestBody {
  operation: Operation;
  resource: string;                 // manifest resource id
  id?: string;                      // get / update / delete
  ids?: string[];                   // action (bulk)
  action?: string;                  // action id from the manifest
  data?: Record<string, unknown>;   // create / update payload
  input?: Record<string, unknown>;  // action inputs
  params?: {
    resource: string;
    page?: number;
    pageSize?: number;              // "limit" is accepted too
    search?: string;
    view?: string;
    filters?: Record<string, string | number | boolean>;
    sort?: { field: string; direction: "asc" | "desc" };
  };
}

export async function POST(request: Request) {
  if (!authorize(request)) return json({ ok: false, error: "Unauthorized" }, 401);
  const body = (await request.json()) as BridgeRequestBody;

  switch (body.operation) {
    // Connection + discovery. "manifest" is REQUIRED: it lets AdminModule PULL the
    // current manifest on demand ("Resync"), in addition to your pushes.
    case "handshake": return json({ ok: true, appName: "Acme Marketplace",
                                    integrationVersion: "1.0.0", protocolVersion: "2026-08-01" });
    case "ping":      return json({ ok: true });
    case "manifest":  return json({ manifest });
    case "list":   return json(await listResource(body));
    case "get":    return json(await getResource(body));
    case "create": return json(await createResource(body));
    case "update": return json(await updateResource(body));
    case "delete": return json(await deleteResource(body));
    case "action": return json(await runAction(body));
    default:       return json({ ok: false, error: "Unsupported operation" }, 400);
  }
}

function json(payload: unknown, status = 200) {
  return new Response(JSON.stringify(payload), {
    status, headers: { "content-type": "application/json" },
  });
}

4. Handshake and manifest publishing

Registering with AdminModule

// Your app -> AdminModule. Run this from SERVER-SIDE code after install,
// and again whenever the manifest changes.

const ADMINMODULE = "{{ADMIN_MODULE_URL}}";           // https://<adminmodule-host>
const headers = {
  "content-type": "application/json",
  "x-adminmodule-project-id": process.env["ADMIN_MODULE_PROJECT_ID"]!,
  "x-adminmodule-secret": process.env["ADMIN_MODULE_SECRET"]!,
};

// 1. Register the connection
await fetch(ADMINMODULE + "/api/public/bridge/handshake", {
  method: "POST",
  headers,
  body: JSON.stringify({
    appName: "Acme Marketplace",
    integrationVersion: "1.0.0",
    bridgeUrl: "https://acme.example.com/api/public/adminmodule-bridge",
  }),
});

// 2. Publish the manifest (can be sent together with the handshake too)
await fetch(ADMINMODULE + "/api/public/bridge/manifest", {
  method: "POST",
  headers,
  body: JSON.stringify({ manifest }),
});

// 3. Optional heartbeat
await fetch(ADMINMODULE + "/api/public/bridge/ping", { method: "POST", headers });

// Response: { ok: true, connected: true, manifestStored: true, next: [] }
//
// DEPLOY BEFORE YOU PUSH
// Ship the bridge logic FIRST, then push the manifest. AdminModule verifies every
// pushed change by pulling { operation: "manifest" } from your live bridge. If
// the deployed app does not serve the same surface yet, the push is held as
// pending — response: { ok: true, manifestPending: true } — and the founder sees
// "Update waiting for app deployment" while the previous working version keeps
// serving. AdminModule promotes it automatically on the next handshake/ping once
// the deployment matches.
//
// Bump integrationVersion on EVERY semantic change (new resource, field, filter,
// view or action). AdminModule warns the founder when the surface changed but
// integrationVersion stayed the same.

After a successful handshake AdminModule marks the project connected and stores the bridge URL, integration version, connection timestamps and the latest manifest. Re-send the manifest whenever resources, fields or actions change — new items appear in the backoffice with no configuration.

The manifest travels both ways. Push it to /api/public/bridge/manifest when it changes, and also answer operation: "manifest" on your bridge so AdminModule can pull it when a founder hits Resync. AdminModule validates it strictly and answers 422 listing every problem it found, so a malformed manifest never silently replaces a working one.

resource is only required for record operations (list, get, create, update, delete, action). Omit it for handshake, manifest and ping. Every successful response uses the same envelope — { ok: true, ... } — and every failure uses { ok: false, error }.

Inbound bridge endpoints are rate limited (60 requests per minute per project) and reject bodies larger than 2 MB, so a runaway loop or malformed payload can never stall a founder's backoffice.

5. Manifest format

Types

interface Manifest {
  protocolVersion: "2026-08-01";
  integrationVersion: string;       // REQUIRED bump on every semantic change
  appName: string;
  resources: Resource[];
  overview?: { metrics?: Metric[] };
}

interface Resource {
  id: string;                       // "orders"
  label: string;                    // "Orders"
  singularLabel: string;            // "Order"
  description?: string;
  icon?: string;                    // lucide-style name, e.g. "receipt"
  // "customer" | "user" | "person" switch the record page to the
  // people layout: avatar, identity, account, related resources, actions.
  kind?: "customer" | "user" | "person" | "default";

  primaryField: string;             // field used as the record title
  subtitleField?: string;
  imageField?: string;
  fields: Field[];
  views?: View[];
  actions?: Action[];
  relationships?: Relationship[];
  metrics?: Metric[];
  capabilities?: { create?: boolean; update?: boolean; delete?: boolean };
}

interface Field {
  id: string;
  label: string;
  type: "text" | "textarea" | "email" | "phone" | "number" | "currency" | "boolean"
      | "status" | "date" | "datetime" | "image" | "images" | "url" | "json" | "relation";
  listed?: boolean;                 // show as a table column
  searchable?: boolean;
  filterable?: boolean;
  sortable?: boolean;
  editable?: boolean;               // AdminModule will offer inline editing
  creatable?: boolean;              // include in the "New record" form
  required?: boolean;
  help?: string;
  group?: string;                   // section title on the record page, e.g. "Billing"
  currency?: string;                // "USD" for type: "currency"
  grouping?: boolean;               // thousands separators for type: "number".
                                    // DEFAULT false -> 2006 renders as "2006".
                                    // Set true only for real quantities (views,
                                    // stock, counts) -> "12,480". Years, IDs,
                                    // postcodes and codes must leave it off.
  // options[] is REQUIRED for any categorical field (status, enum, category,
  // gearbox, fuel type, plan…). With options[] AdminModule renders a dropdown
  // filter and sends the exact value; without it AdminModule can only offer a
  // free-text "contains" filter.
  options?: { value: string; label: string; tone?: "neutral" | "success" | "warning" | "danger" | "info" }[];
  relation?: { resource: string; labelField?: string };
}

// IMAGE FIELDS
// type: "image"  -> an absolute "https://…" URL string (or null)
// type: "images" -> an array of absolute URL strings, or
//                   [{ url: "https://…", alt?: "…" }]
// AdminModule only renders absolute http(s) URLs. Relative paths, storage keys,
// empty strings and unexpected objects are dropped and replaced with a neutral
// placeholder — they never render as a broken image. Sign private URLs before
// returning them.



interface View { id: string; label: string; filters?: Record<string, string | number | boolean>;
                sort?: { field: string; direction: "asc" | "desc" } }

interface Action {
  id: string;                       // "approve-listing"
  label: string;                    // "Approve listing"
  description?: string;
  confirm?: string;                 // AdminModule asks before running
  destructive?: boolean;
  scope?: "record" | "bulk" | "both";
  inputs?: Field[];                 // collected in a dialog and sent as `input`
}

interface Relationship {
  id: string; label: string;
  resource: string;                 // related resource id
  foreignField: string;             // field on the related resource pointing here
  kind?: "hasMany" | "hasOne";
}

interface Metric { id: string; label: string; value: number | string;
                   format?: "number" | "currency" | "percent"; currency?: string; hint?: string }

Example manifest

const manifest = {
  protocolVersion: "2026-08-01",
  integrationVersion: "1.0.0",
  appName: "Acme Marketplace",
  resources: [
    {
      id: "listings",
      label: "Listings",
      singularLabel: "Listing",
      primaryField: "title",
      subtitleField: "city",
      imageField: "photo_url",
      fields: [
        { id: "title", label: "Title", type: "text", listed: true, searchable: true },
        { id: "price", label: "Price", type: "currency", currency: "USD", listed: true, sortable: true },
        { id: "status", label: "Status", type: "status", listed: true, filterable: true, options: [
          { value: "pending", label: "Pending review", tone: "warning" },
          { value: "approved", label: "Approved", tone: "success" },
          { value: "rejected", label: "Rejected", tone: "danger" },
        ] },
        { id: "internal_note", label: "Internal note", type: "textarea", editable: true },
        { id: "created_at", label: "Created", type: "datetime", listed: true, sortable: true },
      ],
      views: [
        { id: "pending", label: "Pending review", filters: { status: "pending" } },
      ],
      actions: [
        { id: "approve-listing", label: "Approve listing", confirm: "Approve this listing?" },
        { id: "reject-listing", label: "Reject listing", destructive: true,
          confirm: "Reject this listing?",
          inputs: [{ id: "reason", label: "Reason", type: "textarea", required: true }] },
      ],
      relationships: [
        { id: "orders", label: "Orders", resource: "orders", foreignField: "listing_id" },
      ],
      // Declare only what is genuinely safe. AdminModule shows write UI for
      // exactly these: a "New customer" form, inline edit, and delete.
      capabilities: { create: true, update: true, delete: false },
    },
  ],
};

6. Listing records

operation: list

// REQUEST
{
  "operation": "list",
  "resource": "customers",
  "params": { "search": "john", "page": 1, "pageSize": 25,
              "filters": { "status": "active" },
              "sort": { "field": "created_at", "direction": "desc" } }
}

// RESPONSE (either shape is accepted)
{
  "data": [ { "id": "cus_1", "name": "John Silva", "status": "active" } ],
  "pagination": { "page": 1, "limit": 25, "total": 482 }
}
// or
{ "records": [ ... ], "page": 1, "pageSize": 25, "total": 482 }

// Rules
// - every record MUST include a stable string "id"
// - apply search over the fields you marked searchable
// - apply filters and sort server-side, exactly as received
// - NEVER silently ignore a filter key. Implement every filter convention for
//   every field you mark filterable:
//     text/email/phone      -> { "<field>": "substring" }        (case-insensitive contains)
//     status/select/enum    -> { "<field>": "exact-value" }      (one of options[].value)
//     boolean               -> { "<field>": true | false }
//     number/currency/date  -> { "<field>_min": x, "<field>_max": y }  (inclusive range)
//     free search           -> { "search": "…" }
//   If a filter key is unknown to you, return an error instead of returning
//   unfiltered rows. AdminModule inspects returned rows and warns the founder when
//   they contradict an active filter.
// - "total" is the unpaginated count

// SAVED VIEWS — AdminModule resolves them for you
// When an operator selects a saved view, AdminModule looks the view up in YOUR
// manifest and expands it: the view's default "filters" and "sort" are merged
// into params, and "view" is still sent as a hint. Operator-chosen filters and
// sorting always override the view defaults.
//
// manifest: views: [{ id: "pending", label: "Pending review",
//                     filters: { status: "pending" },
//                     sort: { field: "created_at", direction: "desc" } }]
//
// outgoing request for that view:
{
  "operation": "list",
  "resource": "listings",
  "params": { "page": 1, "pageSize": 25,
              "view": "pending",
              "filters": { "status": "pending" },
              "sort": { "field": "created_at", "direction": "desc" } }
}
// You therefore only need to honour "filters" and "sort" to support views.
// Never require "view" alone, and never ignore "filters" because "view" is set.

// Filter key conventions AdminModule may send
// "status": "active"        exact match
// "name_contains": "aco"    case-insensitive substring
// "total_min" / "total_max" numeric range
// "created_at_from" / "created_at_to"   date range

7. Individual records

operation: get

// REQUEST
{ "operation": "get", "resource": "customers", "id": "cus_1" }

// RESPONSE
{
  "data": { "id": "cus_1", "name": "John Silva", "email": "john@acme.com",
            "status": "active", "lifetime_value": 4820, "created_at": "2026-02-11T10:00:00Z" },
  "related": {
    "orders": { "total": 12, "records": [ { "id": "ord_9", "total": 240, "status": "paid" } ] }
  }
}
// "record" instead of "data" is also accepted.
// "related" keys are the relationship ids from the manifest.

8. Create, update, delete

operation: create | update | delete

// CREATE
{ "operation": "create", "resource": "customers", "data": { "name": "Ada", "email": "ada@acme.com" } }
-> { "ok": true, "message": "Customer created", "record": { "id": "cus_2", ... } }

// UPDATE — only fields you marked editable are ever sent
{ "operation": "update", "resource": "listings", "id": "lst_7",
  "data": { "internal_note": "Verified by phone" } }
-> { "ok": true, "message": "Listing updated" }

// DELETE
{ "operation": "delete", "resource": "listings", "id": "lst_7" }
-> { "ok": true, "message": "Listing deleted" }

// Validate every write against your own business rules and permissions.
// Reject unknown fields instead of blindly writing them.

// AdminModule only sends create payloads for fields marked creatable, and only
// shows the New / Delete affordances when capabilities declares them.
// If your rules refuse the write, answer 422 with a readable "error": AdminModule
// shows it inline on the form and the project STAYS connected. Reserve non-JSON
// or 5xx responses for genuine failures — those mark the connection unhealthy.

9. Custom actions

operation: action

// REQUEST — AdminModule renders this automatically from the manifest
{
  "operation": "action",
  "resource": "listings",
  "action": "reject-listing",
  "id": "lst_7",
  "ids": ["lst_7"],
  "input": { "reason": "Photos do not match the address" }
}

// RESPONSE
{ "ok": true, "message": "Listing rejected and the seller was notified." }

// Run YOUR existing business logic here (service functions, emails, payments).
// AdminModule does not implement any of it: it shows your message and refreshes.
// Failures: { "ok": false, "error": "Listing already sold" } with status 422.

// Action inputs in the manifest (AdminModule renders the control for you):
// { id: "reason", label: "Reason", type: "textarea", required: true }
// { id: "amount", label: "Refund amount", type: "currency", currency: "USD" }
// { id: "notify", label: "Notify the seller", type: "boolean" }
// { id: "effective", label: "Effective date", type: "date" }
// { id: "tier", label: "Tier", type: "status",
//   options: [ { value: "pro", label: "Pro" }, { value: "scale", label: "Scale" } ] }

10. Error responses

Errors

// Error format for every operation
{ "ok": false, "error": "Human readable message shown to the founder" }

// Status codes
// 401 invalid or missing secret
// 400 malformed request / unknown operation / unknown resource
// 404 record not found
// 422 business rule rejected the operation
// 500 unexpected failure — return a readable message, never an empty body

// Always answer with JSON, even on failure. AdminModule surfaces "error" verbatim.

You don't write this by hand

Create a project in AdminModule and paste the generated prompt into your Lovable app. The coding agent reads this page, implements the bridge and publishes the manifest.

Create a project