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 formeruseOrgSettings).useUsersAdmin— user CRUD plus invitations (the invite methods were merged in from the formeruseInvitesAdmin).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'sSKILL.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/admin → dist/**/*.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:
import { useOrgAdmin, useUsersAdmin } from "@elqnt/admin/hooks"— the two hooks.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.import type { … } from "@elqnt/admin/models"—Org,User,Invite,OrgSettings, etc. (the DTOs).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):
- 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. - Let the compiler check you. Build with
tsc; neveras any/@ts-ignoreyour way around a hook's types to force a call that isn't there. - Never bypass the package. No
fetch/axiosto/api/v1/admin/..., no NATS, no direct service calls. (Server-side: mint a token withcreateServerClientfrom@elqnt/api-client/serverand call the same gateway paths — same routes, same shapes.) - Wrap, don't scatter. Import the hook in exactly one app file
(
hooks/use-<domain>.tsor a context provider); see "The app layer". - 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
productcolumn on the org row. Theproductyou send (JWT claim server-side,X-Productheader browser-side) selects whichadmin_<product>DB the request reads/writes (empty →eloquent).listOrgs/createOrgalso accept a transientproductfield 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 → browserApiRequest → getGatewayToken() 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-Productfrom 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 anadmin/super_adminrole (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 viacreateServerClientdirectly — not the hooks.
Env vars
| Var | Used by | Purpose |
|---|---|---|
JWT_SECRET | /api/gateway-token route + createServerClient | sign the gateway JWT (same value the gateway validates with) |
API_GATEWAY_URL_INTERNAL | createServerClient gatewayUrl | in-cluster gateway URL (server) |
API_GATEWAY_URL_PUBLIC | SSR layout → AppConfigProvider → browser baseUrl | public gateway URL, read at request time (not NEXT_PUBLIC_*) |
ORG_ID | token route / app config | the acting org all requests are scoped to |
Headers the api layer sets per request
| From hook option | Header |
|---|---|
| auto token | Authorization: Bearer <token> |
orgId | X-Org-ID |
userId | X-User-ID |
userEmail | X-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 aggregateloading/errorflags. On failure a method returns its default ([],null,false) and setserror— it does not throw.Callback identity. Hook methods are recreated when
optionschange (the deps array is[options]). If you need a stable ref insideuseEffect/useCallback, stash it in auseRef: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):
| Method | Signature | Resolves to | Endpoint |
|---|---|---|---|
listOrgs | (filter?: ListOrgsFilter) => Promise<Org[]> | [] on error | GET /api/v1/admin/orgs (?status&type&product) |
getOrg | (orgId: string) => Promise<Org | null> | null | GET /api/v1/admin/orgs/{orgId} |
getOrgInfo | (orgId: string) => Promise<OrgInfo | null> | null | GET /api/v1/admin/orgs/{orgId}/info |
createOrg | (org: Partial<Org> & { product?: string }) => Promise<Org | null> | created org | POST /api/v1/admin/orgs |
updateOrg | (orgId: string, updates: Partial<Org>) => Promise<Org | null> | updated org | PUT /api/v1/admin/orgs/{orgId} |
deleteOrg | (orgId: string) => Promise<boolean> | true/false | DELETE /api/v1/admin/orgs/{orgId} |
getSettings | () => Promise<OrgSettings | null> | null | GET /api/v1/admin/orgs/{orgId} → mapped to OrgSettings |
createSettings | (settings: Partial<OrgSettings>) => Promise<OrgSettings | null> | settings | alias of updateSettings (settings rows always exist) |
updateSettings | (settings: Partial<OrgSettings>) => Promise<OrgSettings | null> | settings | PUT /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'sproductfield is transient routing metadata (whichadmin_<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):
| Method | Signature | Resolves to | Endpoint |
|---|---|---|---|
listUsers | () => Promise<User[]> | [] | GET /api/v1/admin/users |
getUser | (userId: string) => Promise<User | null> | null | GET /api/v1/admin/users/{userId} |
getUserByEmail | (email: string) => Promise<User | null> | null | GET /api/v1/admin/users/by-email?email= |
getUserByPhone | (phone: string) => Promise<User | null> | null | GET /api/v1/admin/users/by-phone?phone= |
createUser | (user: Partial<User>) => Promise<User | null> | created user | POST /api/v1/admin/users |
updateUser | (userId: string, updates: Partial<User>) => Promise<User | null> | updated user | PUT /api/v1/admin/users/{userId} |
deleteUser | (userId: string) => Promise<boolean> | bool | DELETE /api/v1/admin/users/{userId} |
getUserSettings | (userId: string) => Promise<UserSettingsResponse | null> | null | GET .../users/{userId}/settings |
updateUserSettings | (userId: string, settings: { settings?: UserSettings; notificationPreferences?: NotificationPreferences }) => Promise<UserSettingsResponse | null> | settings | PUT .../users/{userId}/settings |
listInvites | () => Promise<Invite[]> | [] | GET /api/v1/admin/invites |
getInvite | (inviteId: string) => Promise<Invite | null> | null | GET /api/v1/admin/invites/{inviteId} |
sendInvite | (invite: InviteInput) => Promise<Invite | null> | invite | POST /api/v1/admin/invites/single |
sendInvites | (invites: InviteInput[]) => Promise<InvitesResult | null> | batch result | POST /api/v1/admin/invites body { invites } |
resendInvite | (inviteId: string) => Promise<Invite | null> | invite | POST .../invites/{inviteId}/resend |
revokeInvite | (inviteId: string) => Promise<boolean> | bool | DELETE /api/v1/admin/invites/{inviteId} |
acceptInvite | (inviteId: string) => Promise<Invite | null> | invite | POST .../invites/{inviteId}/accept |
interface InviteInput { email: string; role: string; }
interface InvitesResult { sent: InviteSentStatus[]; failed: string[]; nextStep: string; metadata: ResponseMetadata; }
Invitations (
listInvites…acceptInvite) were merged in from the formeruseInvitesAdmin. They share this hook's aggregateloading/error.
Product analytics lives in
@elqnt/analyticsnow — importuseProductAnalyticsfrom@elqnt/analytics/hooksand its DTOs (AnalyticsSummary,DateFilter, …) from@elqnt/analytics/models. See that package'sSKILL.md.
Core types (@elqnt/admin/models)
Admin/billing types are tygo-generated (
models/admin.ts,models/billing.ts).OrgSettingsis 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
| Fn | Endpoint |
|---|---|
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)
| Fn | Endpoint |
|---|---|
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
| Fn | Endpoint |
|---|---|
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) anduseUsersAdmin(user CRUD + invitations). Both are"use client". For SSR/server actions, call the gateway paths viacreateServerClient, not the hooks. (Product analytics moved to@elqnt/analytics—useProductAnalytics.) - Methods don't throw. They return defaults (
[],null,false) and seterror. Checkerror/ 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(injectbaseUrl/orgIdonce via SSR) and a per-domain context. - Callbacks are unstable. Methods are recreated when
optionschange; use auseRefif you need a stable reference insideuseEffectdeps. productselects the DB, it is not stored. The gateway resolves product from the JWT claim (server) andX-Product(browser).listOrgs/createOrg'sproductfield is transient routing metadata — mismatched product = wrongadmin_<product>DB.- Org settings live on the Org row.
useOrgAdmin'sgetSettings/updateSettingsandget/updateOrgSettingsApihit/api/v1/admin/orgs/{orgId}and re-map to the legacyOrgSettingsshape — there is no separate settings endpoint. - Per-org artifact provisioning is gone.
provisionDefaultAgentsApi/provisionEntitiesApi/provisionWorkflowsApiare deprecated; use the domain packages. Seedocs/_db_v2/03_system_org_split.md.