Eloquent

Documentation

<!-- AUTO-GENERATED from packages/@elqnt/admin/SKILL.md — do not edit here. Run `pnpm docs:sync`. -->
npm install @elqnt/admin

Admin — Building an Admin / Platform Frontend

Eloquent's admin service owns the platform's control plane: organizations, users, invitations, org settings, onboarding, and billing. This skill is only about one thing: building a frontend (the admin console, an onboarding wizard) that drives it through the @elqnt/admin package.

Package is @elqnt/admin. It exports two React hooks:

  • useOrgAdmin — organization CRUD plus org settings (the settings methods were merged in from the former useOrgSettings).
  • useUsersAdmin — user CRUD plus invitations (the invite methods were merged in from the former useInvitesAdmin).

plus a set of api functions for the flows that have no hook yet (onboarding wizard, billing/Stripe). Hooks are the primary interface; api functions are the building blocks the hooks wrap.

Product analytics moved out to its own package — @elqnt/analytics (useProductAnalytics). See that package's SKILL.md.


⛔ The contract — read this before writing any code

This skill ships inside the package, so the contract is not a separate file to keep in sync — it is the package's own published type declarations (@elqnt/admindist/**/*.d.ts). Because your app imports the real hooks, the TypeScript compiler enforces the contract for you: a wrong param or a misused return value won't compile.

The single source of truth, in order:

  1. import { useOrgAdmin, useUsersAdmin } from "@elqnt/admin/hooks" — the two hooks.
  2. import type { UseOrgAdminReturn, UseUsersAdminReturn } from "@elqnt/admin/hooks" — the named, exact method surface of each hook (every method, its params, its return type). The tables below are a human-readable mirror of these.
  3. import type { … } from "@elqnt/admin/models"Org, User, Invite, OrgSettings, etc. (the DTOs).
  4. import { … } from "@elqnt/admin/api" — the onboarding/billing api functions (and the lower-level CRUD api fns the hooks wrap) for flows no hook covers.

Rules (do not drift):

  1. Use only the two exported hooks (and the documented api functions) and only the methods/fns they expose. Do not invent hooks, methods, params, or return shapes — if it's not on UseOrgAdminReturn / UseUsersAdminReturn / the exported api signatures, it doesn't exist.
  2. Let the compiler check you. Build with tsc; never as any / @ts-ignore your way around a hook's types to force a call that isn't there.
  3. Never bypass the package. No fetch/axios to /api/v1/admin/..., no NATS, no direct service calls. (Server-side: mint a token with createServerClient from @elqnt/api-client/server and call the same gateway paths — same routes, same shapes.)
  4. Wrap, don't scatter. Import the hook in exactly one app file (hooks/use-<domain>.ts or a context provider); see "The app layer".
  5. If a requirement seems to need a call the package doesn't define, stop and flag it — do not improvise an endpoint.

The prose/tables below explain each method; the package's shipped .d.ts is what your code type-checks against.


Architecture — the one and only path

A component never calls a package hook directly. It only sees your domain context (orgs list, current user, invite table), served by a context provider, backed by your app hook, which is the only place that wraps the @elqnt/admin hook. Everything below the app hook is plumbing the component never imports.

┌──────────────────────────────────────────────────────────────────────────────┐
│  YOUR ADMIN APP (Next.js / React)                                              │
│                                                                                │
│  SSR layout: read API_GATEWAY_URL_PUBLIC (request-time) + ORG_ID               │
│       └─► AppConfigProvider { apiGatewayUrl, orgId }                           │
│                                                                                │
│  components/orgs/*   ── speak only the Org / User / Invite domain types         │
│         │  useOrgsContext()                                                     │
│         ▼                                                                       │
│  contexts/orgs-context.tsx   ── one shared app-hook instance                   │
│         │                                                                       │
│         ▼                                                                       │
│  hooks/use-orgs.ts   ── app hook: CRUD + state                                 │
│         │                                                                       │
│         ▼                                                                       │
│  useOrgAdmin / useUsersAdmin   (@elqnt/admin/hooks)                            │
│         │                                                                       │
│         ▼                                                                       │
│  @elqnt/admin/api  →  browserApiRequest                                        │
│         │  getGatewayToken() ⇒ GET /api/gateway-token (mint HS256 JWT, JWT_SECRET)│
│         │  attaches: Authorization: Bearer <jwt>, X-Org-ID, X-User-ID, X-Product│
└─────────┼──────────────────────────────────────────────────────────────────────┘
          ▼
   ┌─────────────────┐   verify JWT, stamp X-Org-ID/X-Product, route match
   │   API GATEWAY   │   /api/v1/admin/** , /api/v1/onboarding/** ,
   └───────┬─────────┘   /api/v1/billing/**  → admin svc
           ▼
   ┌─────────────────┐   per-product admin DB admin_<product> (row-level tenancy)
   │  admin (Go)     │   orgs / users / invites / settings / onboarding / billing
   └─────────────────┘

Rules: the frontend never calls the admin service directly and never touches NATS. Every call is HTTP through the gateway carrying an org id + gateway token. And components never import @elqnt/admin — only the app hook does.

Product routing. There is no product column on the org row. The product you send (JWT claim server-side, X-Product header browser-side) selects which admin_<product> DB the request reads/writes (empty → eloquent). listOrgs/createOrg also accept a transient product field as routing metadata; it is not persisted on the org.


The gateway token (the secret) — how the app gets it

Every request to the gateway needs Authorization: Bearer <token>. The token is a short-lived HS256 JWT signed with the shared gateway secret. You never hardcode it; there are two flows depending on where the code runs.

Browser flow (what the hooks use)

The hooks → api fns → browserApiRequestgetGatewayToken() internally. You do not pass a token to the hook. getGatewayToken() (from @elqnt/api-client/browser) by default does:

fetch("/api/gateway-token")   ⇒  { token, expiresIn }

So your admin app must expose a /api/gateway-token route that mints the JWT server-side (the secret stays on the server, never reaches the browser). The client caches the token and refreshes ~5 min before expiry.

// app/api/gateway-token/route.ts  (Next.js route handler — server only)
import { NextResponse } from "next/server";
import * as jose from "jose";

export async function GET() {
  const secret = new TextEncoder().encode(process.env.JWT_SECRET!);  // SAME secret the gateway validates with
  const token = await new jose.SignJWT({
    org_id:  process.env.ORG_ID!,
    user_id: "system",
    email:   "system@admin-app.com",
    role:    "admin",                                // admin routes typically require admin/super_admin
    scopes:  ["read", "write", "admin"],             // OR-matched against the route's required scopes
    product: "eloquent",                             // gateway resolves product from THIS claim first
  })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setIssuer("eloquent-gateway")     // must match gateway JWT_ISSUER
    .setAudience("eloquent-api")       // must match gateway JWT_AUDIENCE
    .setExpirationTime("1h")
    .sign(secret);

  return NextResponse.json({ token, expiresIn: 3600 });
}

The gateway re-stamps X-Org-ID / X-User-ID / X-Product from the signed JWT claims before proxying, so a forged header can't override the token — the JWT is authoritative. Most /api/v1/admin/** and analytics-global routes require an admin / super_admin role (or a * scope) — mint the token accordingly.

Override the token source (non-default URL, native app) once at startup:

import { configureAuth } from "@elqnt/api-client/browser";
configureAuth(async () => myTokenProvider());     // URL string or async () => token|null
// clearGatewayTokenCache()  — re-exported from @elqnt/admin/api; call after switching orgs

Server flow (server actions / SSR)

@elqnt/api-client/server mints the JWT itself with jose — no /api/gateway-token hop. This is where the secret is injected directly:

import { createServerClient } from "@elqnt/api-client/server";

const client = createServerClient({
  gatewayUrl: process.env.API_GATEWAY_URL_INTERNAL!,  // in-cluster gateway URL (server-side)
  jwtSecret:  process.env.JWT_SECRET!,                // the shared gateway secret
  defaultProduct: "eloquent",                          // gateway reads product from the JWT claim first
  defaultScopes: ["read", "write", "admin"],
});

await client.get("/api/v1/admin/orgs", { orgId, userId });

The admin hooks are browser-only ("use client"). For SSR/server actions call the gateway paths via createServerClient directly — not the hooks.

Env vars

VarUsed byPurpose
JWT_SECRET/api/gateway-token route + createServerClientsign the gateway JWT (same value the gateway validates with)
API_GATEWAY_URL_INTERNALcreateServerClient gatewayUrlin-cluster gateway URL (server)
API_GATEWAY_URL_PUBLICSSR layout → AppConfigProvider → browser baseUrlpublic gateway URL, read at request time (not NEXT_PUBLIC_*)
ORG_IDtoken route / app configthe acting org all requests are scoped to

Headers the api layer sets per request

From hook optionHeader
auto tokenAuthorization: Bearer <token>
orgIdX-Org-ID
userIdX-User-ID
userEmailX-User-Email
product (default "eloquent")X-Product

Hook options (all hooks)

There is no provider/context baked into the package. You pass options into each hook call. Every hook's options is just ApiClientOptions:

interface ApiClientOptions {
  baseUrl: string;     // API Gateway base URL — required
  orgId: string;       // required → X-Org-ID
  userId?: string;     // → X-User-ID
  userEmail?: string;  // → X-User-Email
  product?: string;    // → X-Product, defaults to "eloquent"; selects admin_<product> DB
  headers?: Record<string, string>;
}

type UseOrgAdminOptions   = ApiClientOptions;
type UseUsersAdminOptions = ApiClientOptions;

Imperative, not auto-fetching. Every method returns a Promise you await. The hooks expose aggregate loading / error flags. On failure a method returns its default ([], null, false) and sets error — it does not throw.

Callback identity. Hook methods are recreated when options change (the deps array is [options]). If you need a stable ref inside useEffect/ useCallback, stash it in a useRef:

const listOrgsRef = useRef(listOrgs);
listOrgsRef.current = listOrgs;

Hook: useOrgAdmin — organizations + settings

import { useOrgAdmin } from "@elqnt/admin/hooks";
const orgs = useOrgAdmin({ baseUrl, orgId, userId, userEmail });

Returns { loading, error, ...methods } (UseOrgAdminReturn):

MethodSignatureResolves toEndpoint
listOrgs(filter?: ListOrgsFilter) => Promise<Org[]>[] on errorGET /api/v1/admin/orgs (?status&type&product)
getOrg(orgId: string) => Promise<Org | null>nullGET /api/v1/admin/orgs/{orgId}
getOrgInfo(orgId: string) => Promise<OrgInfo | null>nullGET /api/v1/admin/orgs/{orgId}/info
createOrg(org: Partial<Org> & { product?: string }) => Promise<Org | null>created orgPOST /api/v1/admin/orgs
updateOrg(orgId: string, updates: Partial<Org>) => Promise<Org | null>updated orgPUT /api/v1/admin/orgs/{orgId}
deleteOrg(orgId: string) => Promise<boolean>true/falseDELETE /api/v1/admin/orgs/{orgId}
getSettings() => Promise<OrgSettings | null>nullGET /api/v1/admin/orgs/{orgId} → mapped to OrgSettings
createSettings(settings: Partial<OrgSettings>) => Promise<OrgSettings | null>settingsalias of updateSettings (settings rows always exist)
updateSettings(settings: Partial<OrgSettings>) => Promise<OrgSettings | null>settingsPUT /api/v1/admin/orgs/{orgId}, then re-GET
interface ListOrgsFilter { status?: OrgStatusTS; type?: OrgTypeTS; product?: string; }
// → ?status=active&type=enterprise&product=eloquent  (product = which admin_<product> DB)

createOrg's product field is transient routing metadata (which admin_<product> DB to write to) — it is not persisted on the org row.

Org settings (getSettings/createSettings/updateSettings, merged from the former useOrgSettings) live on the canonical admin Org row (since v2.3) and are scoped to the hook's options.orgId. The hook keeps the legacy OrgSettings wire shape, but all three hit /api/v1/admin/orgs/{orgId} under the hood. updateSettings PUTs { title, description, logoUrl, defaultLang, timezone, settings } (mapped from the OrgSettings snake_case fields) and round-trips a fresh GET so you get back the full settings shape.


Hook: useUsersAdmin — users + invitations

import { useUsersAdmin } from "@elqnt/admin/hooks";
const users = useUsersAdmin({ baseUrl, orgId, userId, userEmail });

Returns { loading, error, ...methods } (UseUsersAdminReturn):

MethodSignatureResolves toEndpoint
listUsers() => Promise<User[]>[]GET /api/v1/admin/users
getUser(userId: string) => Promise<User | null>nullGET /api/v1/admin/users/{userId}
getUserByEmail(email: string) => Promise<User | null>nullGET /api/v1/admin/users/by-email?email=
getUserByPhone(phone: string) => Promise<User | null>nullGET /api/v1/admin/users/by-phone?phone=
createUser(user: Partial<User>) => Promise<User | null>created userPOST /api/v1/admin/users
updateUser(userId: string, updates: Partial<User>) => Promise<User | null>updated userPUT /api/v1/admin/users/{userId}
deleteUser(userId: string) => Promise<boolean>boolDELETE /api/v1/admin/users/{userId}
getUserSettings(userId: string) => Promise<UserSettingsResponse | null>nullGET .../users/{userId}/settings
updateUserSettings(userId: string, settings: { settings?: UserSettings; notificationPreferences?: NotificationPreferences }) => Promise<UserSettingsResponse | null>settingsPUT .../users/{userId}/settings
listInvites() => Promise<Invite[]>[]GET /api/v1/admin/invites
getInvite(inviteId: string) => Promise<Invite | null>nullGET /api/v1/admin/invites/{inviteId}
sendInvite(invite: InviteInput) => Promise<Invite | null>invitePOST /api/v1/admin/invites/single
sendInvites(invites: InviteInput[]) => Promise<InvitesResult | null>batch resultPOST /api/v1/admin/invites body { invites }
resendInvite(inviteId: string) => Promise<Invite | null>invitePOST .../invites/{inviteId}/resend
revokeInvite(inviteId: string) => Promise<boolean>boolDELETE /api/v1/admin/invites/{inviteId}
acceptInvite(inviteId: string) => Promise<Invite | null>invitePOST .../invites/{inviteId}/accept
interface InviteInput  { email: string; role: string; }
interface InvitesResult { sent: InviteSentStatus[]; failed: string[]; nextStep: string; metadata: ResponseMetadata; }

Invitations (listInvitesacceptInvite) were merged in from the former useInvitesAdmin. They share this hook's aggregate loading/error.

Product analytics lives in @elqnt/analytics now — import useProductAnalytics from @elqnt/analytics/hooks and its DTOs (AnalyticsSummary, DateFilter, …) from @elqnt/analytics/models. See that package's SKILL.md.


Core types (@elqnt/admin/models)

Admin/billing types are tygo-generated (models/admin.ts, models/billing.ts). OrgSettings is re-exported from @elqnt/agents/models. (Analytics DTOs moved to @elqnt/analytics/models.)

interface Org {
  id?: string;
  title: string;
  description?: string;
  logoUrl: string;
  mainDomain: string;
  address: Address;
  defaultLang?: string; timezone?: string;
  settings?: Record<string, any>;
  status: OrgStatusTS;            // "active" | "suspended"
  enabled: boolean;
  type: OrgTypeTS;               // "self-serve" | "enterprise"
  size?: OrgSizeTS;             // "solo" | "small" | "medium" | "large" | "enterprise"
  industry?: string;
  country?: string;             // jurisdiction code (e.g. "UAE") → scopes system libraries
  tags?: string[];
  subscription?: OrgSubscription;
  userCount: number;
  onboarding?: OrgOnboarding;
  template?: OrgTemplate; roles: string[];
  metadata?: Record<string, any>;
  createdAt?: number; updatedAt?: number; createdBy?: string; updatedBy?: string;
}
interface OrgInfo { id?: string; title: string; logoUrl: string; mainDomain?: string; }

interface User {
  id?: string;
  email: string; firstName: string; lastName: string;
  authProviderName: string;
  orgAccess?: UserOrgAccess[];
  enabled?: boolean;
  source?: UserSourceTS;        // "signup" | "invite" | "sso" | "api"
  inviteStatus?: InviteStatusTS;
  isSysAdmin?: boolean;
  settings?: UserSettings;
  notificationPreferences?: NotificationPreferences;
  onboarding?: UserOnboarding;
  metadata?: Record<string, any>;
  firstActiveAt?: number; lastActiveAt?: number;
  createdAt?: number; updatedAt?: number; createdBy?: string; updatedBy?: string;
}

interface Invite {
  id?: string; orgId: string; email: string; role: string;
  invitedBy: string;
  status: InviteStatusTS;       // "pending" | "accepted" | "expired" | "revoked"
  acceptedBy?: string; acceptedAt?: number; expiresAt?: number;
  createdAt?: number; updatedAt?: number;
}

interface UserSettings { theme?: string; language?: string; timezone?: string; occupation?: string; company?: string; }
interface NotificationPreferences {
  pushEnabled: boolean; newChatAssignment: boolean; newMessages: boolean;
  escalations: boolean; urgentOnly: boolean; soundEnabled: boolean;
  doNotDisturb: boolean; dndStart?: string; dndEnd?: string;
}

Analytics DTOs (AnalyticsSummary, ChatAnalytics, OrgAnalytics, GlobalChannelSummary, …) moved to @elqnt/analytics/models.


API functions (@elqnt/admin/api) — for flows with no hook

Hooks cover orgs/settings (useOrgAdmin) and users/invites (useUsersAdmin). The onboarding wizard and billing/Stripe flows have no hook — call their api functions directly (with browserApiRequest token handling already wired in). All take an options object (OnboardingApiOptions allows a missing orgId; the rest take ApiClientOptions) and return ApiResponse<T> ({ data?, error?, status } — check error).

Onboarding wizard

FnEndpoint
getOnboardingStatusApi(options)GET /api/v1/onboarding/status
startOnboardingApi(options)POST /api/v1/onboarding/start
createPaymentSessionApi(params, options)POST /api/v1/onboarding/step/payment
createOrganizationApi(org, options)POST /api/v1/onboarding/step/organization
sendOnboardingInvitesApi(invites, options)POST /api/v1/onboarding/step/invites
createOnboardingKnowledgeApi(knowledge, options)POST /api/v1/onboarding/step/knowledge
createOnboardingAgentApi(agent, options)POST /api/v1/onboarding/step/agent
createAgentWithSkillsApi(payload, options)POST /api/v1/onboarding/agent-with-skills
completeOnboardingApi(options)POST /api/v1/onboarding/complete
skipOnboardingStepApi(step, options)POST /api/v1/onboarding/skip-step
startOnboardingProvisioningApi(options)POST /api/v1/onboarding/provisioning/start{ orgId } then connect SSE
provisionAllOnboardingApi(request, options)POST /api/v1/onboarding/provision-all — unified collect-then-provision

Org settings / org artifacts (api-level)

FnEndpoint
getOrgSettingsApi(options)GET /api/v1/admin/orgs/{orgId} (→ OrgSettings)
updateOrgSettingsApi(settings, options)PUT /api/v1/admin/orgs/{orgId}, then re-GET
createOrgSettingsApi(settings, options)deprecated alias of updateOrgSettingsApi
updateOrgAgentsApi(agentIds, options)PUT /api/v1/org/agents
updateEntityDefinitionsApi(entityNames, options)PUT /api/v1/org/entities
updateWorkflowDefinitionsApi(workflowIds, options)PUT /api/v1/org/workflows

Billing

FnEndpoint
getBillingPlansApi(options)GET /api/v1/billing/plans
getSubscriptionApi(options)GET /api/v1/billing/subscription
getCreditsApi(options)GET /api/v1/billing/credits
createCheckoutSessionApi(params, options)POST /api/v1/billing/checkout
createPortalSessionApi(params, options)POST /api/v1/billing/portal
cancelSubscriptionApi(options)POST /api/v1/billing/subscription/cancel
purchaseCreditsApi(params, options)POST /api/v1/billing/credits/purchase

Deprecated — do not use. provisionDefaultAgentsApi, provisionEntitiesApi, provisionWorkflowsApi (per-org artifact provisioning is gone — use the domain packages @elqnt/agents/api, @elqnt/entity/api, etc.). The lower-level CRUD api fns (listOrgsApi, createUserApi, sendInvitesApi, …) exist too, but prefer the hooks for app code. (Analytics api fns moved to @elqnt/analytics/api.)


The app layer

Wrap the hook in one app file and a context, so components speak the domain type, not the gateway. Read baseUrl/orgId from SSR (an AppConfig context fed by API_GATEWAY_URL_PUBLIC at request time), never a NEXT_PUBLIC_* var.

// contexts/app-config-context.tsx
"use client";
import { createContext, useContext, type ReactNode } from "react";

export interface AppConfig { apiGatewayUrl: string; orgId: string; }
const Ctx = createContext<AppConfig | undefined>(undefined);
export function AppConfigProvider({ children, initialConfig }: { children: ReactNode; initialConfig: AppConfig; }) {
  return <Ctx.Provider value={initialConfig}>{children}</Ctx.Provider>;
}
export function useAppConfig() {
  const c = useContext(Ctx);
  if (!c) throw new Error("useAppConfig must be used within AppConfigProvider");
  return c;
}
// app/layout.tsx  (server component) — read the gateway URL at request time
import { AppConfigProvider } from "@/contexts/app-config-context";
const apiGatewayUrl = process.env.API_GATEWAY_URL_PUBLIC || "http://localhost:8080"; // NOT NEXT_PUBLIC_
export default async function Layout({ children }: { children: React.ReactNode }) {
  return (
    <AppConfigProvider initialConfig={{ apiGatewayUrl, orgId: process.env.ORG_ID! }}>
      {children}
    </AppConfigProvider>
  );
}
// contexts/orgs-context.tsx — one shared hook instance for the orgs subtree
"use client";
import { createContext, useContext, type ReactNode } from "react";
import { useOrgAdmin, type UseOrgAdminReturn } from "@elqnt/admin/hooks";
import { useAppConfig } from "@/contexts/app-config-context";

const OrgsCtx = createContext<UseOrgAdminReturn | undefined>(undefined);
export function OrgsProvider({ children }: { children: ReactNode }) {
  const { apiGatewayUrl, orgId } = useAppConfig();
  const value = useOrgAdmin({ baseUrl: apiGatewayUrl, orgId });  // single instance
  return <OrgsCtx.Provider value={value}>{children}</OrgsCtx.Provider>;
}
export function useOrgsContext(): UseOrgAdminReturn {
  const c = useContext(OrgsCtx);
  if (!c) throw new Error("useOrgsContext must be used within OrgsProvider");
  return c;
}
// components/orgs/orgs-table.tsx — consume the context, never import @elqnt/admin
"use client";
import { useEffect, useState } from "react";
import { useOrgsContext } from "@/contexts/orgs-context";
import type { Org } from "@elqnt/admin/models";

export function OrgsTable() {
  const { listOrgs, loading, error } = useOrgsContext();
  const [rows, setRows] = useState<Org[]>([]);
  useEffect(() => { listOrgs({ status: "active" }).then(setRows); }, [listOrgs]);
  if (error) return <p>{error}</p>;
  return <ul>{rows.map((o) => <li key={o.id}>{o.title} — {o.userCount} users</li>)}</ul>;
}

Rule of thumb: @elqnt/admin is imported in exactly the context/app-hook file. Everything above it speaks Org / User / Invite. Typed Use*Return imports (UseOrgAdminReturn, …) are how the context carries the hook shape.


Gotchas

  • Two hooks, browser-only. useOrgAdmin (org CRUD + settings) and useUsersAdmin (user CRUD + invitations). Both are "use client". For SSR/server actions, call the gateway paths via createServerClient, not the hooks. (Product analytics moved to @elqnt/analyticsuseProductAnalytics.)
  • Methods don't throw. They return defaults ([], null, false) and set error. Check error / null results; don't wrap in try/catch expecting throws.
  • No provider in the package. Options go into every hook call — which is why your app builds AppConfigProvider (inject baseUrl/orgId once via SSR) and a per-domain context.
  • Callbacks are unstable. Methods are recreated when options change; use a useRef if you need a stable reference inside useEffect deps.
  • product selects the DB, it is not stored. The gateway resolves product from the JWT claim (server) and X-Product (browser). listOrgs/createOrg's product field is transient routing metadata — mismatched product = wrong admin_<product> DB.
  • Org settings live on the Org row. useOrgAdmin's getSettings/updateSettings and get/updateOrgSettingsApi hit /api/v1/admin/orgs/{orgId} and re-map to the legacy OrgSettings shape — there is no separate settings endpoint.
  • Per-org artifact provisioning is gone. provisionDefaultAgentsApi / provisionEntitiesApi / provisionWorkflowsApi are deprecated; use the domain packages. See docs/_db_v2/03_system_org_split.md.