Eloquent

Documentation

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

Agents — Designing & Saving an Agent

Eloquent's agents service is the agent registry: an agent is a saved configuration object — an LLM config (provider/model/temperature/maxTokens/ systemPrompt) plus an attached skills array, tools, and CSAT/handoff config. This skill is only about one thing: building a custom app that designs and saves an agent through the @elqnt/agents package.

Two halves, two packages. Designing/saving an agent is @elqnt/agents (this skill). Chatting with the saved agent is a different package with a different auth model — @elqnt/chat (its own SKILL.md). Bind the two by passing { agentId } (or { agentName }) when you start a chat thread; this skill does not cover chat. Cross-link: @elqnt/chatSKILL.md.

ConcernPackageBackendGateway prefixAuth
Design/save an agent (this skill)@elqnt/agentsbackend/services/agents/api/v1/agents/…@elqnt/api-client auto-JWT (bearer)
Chat with it (separate skill)@elqnt/chatbackend/services/chat/api/v1/chat/…realtime = origin allow-list (no bearer)

⛔ 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/agentsdist/**/*.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 { useAgents, useSkills, useToolDefinitions, useSubAgents, useWidgets } from "@elqnt/agents/hooks" — the design-time hooks.
  2. import type { UseAgentsReturn, UseSkillsReturn, UseToolDefinitionsReturn, UseSubAgentsReturn, UseWidgetsReturn } from "@elqnt/agents/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 { Agent, AgentSkill, AgentTool, Skill, ToolDefinition, SubAgent, AgentWidget, CSATConfig, HandoffConfig, AgentSummary } from "@elqnt/agents/models" — the DTOs you build and read.

Rules (do not drift):

  1. Use only the exported hooks and only the methods they expose. Do not invent hooks, methods, params, or return shapes — if it's not on UseAgentsReturn (etc.), 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 hooks. No fetch/axios to /api/v1/agents/..., no NATS, no direct service calls. (Server-side only: @elqnt/agents/api + createServerClient, same signatures.)
  4. Wrap, don't scatter. Import the agents hook in exactly one app file (your admin/design hook, e.g. hooks/use-agent-designer.ts) and have components consume it through a context.
  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

┌──────────────────────────────────────────────────────────────────────────────┐
│  YOUR CUSTOM APP (Next.js / React)                                             │
│                                                                                │
│  SSR layout: read API_GATEWAY_URL_PUBLIC (request-time) + ORG_ID               │
│       └─► AppConfigProvider { apiGatewayUrl, orgId }                           │
│                                                                                │
│  components/agent-builder/*  ── render the Agent designer UI                   │
│         │  useAgentsContext()                                                  │
│         ▼                                                                       │
│  contexts/agents-context.tsx   ── one shared useAgents() instance              │
│         │                                                                       │
│         ▼                                                                       │
│  hooks/use-agent-designer.ts   ── app hook: the ONLY file importing            │
│         │                          @elqnt/agents; CRUD + state                 │
│         ▼                                                                       │
│  useAgents / useSkills / useToolDefinitions / …   (@elqnt/agents/hooks)        │
│         │                                                                       │
│         ▼                                                                       │
│  @elqnt/agents/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/agents/** → agents svc
   └───────┬─────────┘
           ▼
   ┌─────────────────┐   agent CRUD, skills/tools/sub-agents/widgets
   │  agents (Go)    │   per-product storage, scoped by org_id
   └─────────────────┘

Rules: the frontend never calls the agents service directly and never touches NATS. Every call is HTTP through the gateway carrying an org id + gateway token.

Domain layer is OPTIONAL here. Unlike the entities backend (where a raw EntityRecord is a generic { id, name, fields } envelope that must be wrapped in translators), an Agent is already a rich, camelCase domain type. There's nothing to unwrap. So a translator layer is usually overkill — components can speak Agent/AgentSummary directly. Still keep the single wrap point: @elqnt/agents is imported in one app hook, and components consume it via a context (one shared instance, not N re-fetches). Add a translator only if you genuinely need a different domain shape than Agent.


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

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

Browser flow (what useAgents uses)

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 custom 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@my-app.com",
    role:    "system",
    scopes:  ["read:agents", "write:agents"],   // OR-matched against the route's required scopes
    product: "my-product",                       // 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. Routes declare required scopes (e.g. write:agents); the check is an OR match, and admin/super_admin role or a * scope bypasses it.

Override the token source (mobile/native, or a non-default URL) once at startup:

import { configureAuth } from "@elqnt/api-client/browser";
configureAuth(async () => myTokenProvider());     // URL string or async () => token|null
// clearGatewayTokenCache()  — 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: "my-product",                        // REQUIRED: gateway reads product from the JWT claim first
  defaultScopes: ["read", "write", "admin"],
});

await client.get("/api/v1/agents/by-name?name=support_concierge", { orgId });

The agents hooks are browser-only ("use client"). For SSR/server actions call the API fns or 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 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

There is no provider/context in the package. You pass options into each hook call. All hooks extend ApiClientOptions:

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

type UseAgentsOptions          = ApiClientOptions;
type UseSkillsOptions          = ApiClientOptions;
type UseToolDefinitionsOptions = ApiClientOptions;
type UseSubAgentsOptions       = ApiClientOptions;

interface UseWidgetsOptions extends ApiClientOptions {
  agentId: string;     // the agent whose widgets you manage — required
}

Imperative, not auto-fetching. Every method is built on useApiAsync and returns an execute function. There is no data/auto-loading per call — you await listAgents() to get data; the hook exposes aggregate loading and error flags. On failure a method returns its default ([], null, false) and sets error — it does not throw. exportAgent follows the same no-throw contract: it returns the exported Agent, or null on failure (with error set), and as a documented side-effect triggers a .agent.json browser download when an agent is returned.


The Agent type (full)

From @elqnt/agents/models (JSONSchema from @elqnt/types):

type AgentTypeTS    = 'chat' | 'react';
type AgentSubTypeTS = 'chat' | 'react' | 'workflow' | 'document';
type AgentStatusTS  = 'draft' | 'active' | 'inactive' | 'archived';
type AgentScopeTS   = 'org' | 'team' | 'user' | 'custom';

interface Agent {
  id?: string;
  orgId: string;
  product: string;
  type: AgentTypeTS;
  subType: AgentSubTypeTS;
  name: string;          // machine name, e.g. "rfp_builder"
  title: string;         // human title, e.g. "RFP Builder"
  description?: string;
  status: AgentStatusTS;
  version: string;

  // === LLM CONFIG (flat on the agent) ===
  provider: string;      // "anthropic" | "openai" | "azure-openai"
  model: string;         // logical model name, e.g. "claude-sonnet-4-5"
  temperature: number;
  maxTokens: number;
  systemPrompt: string;
  goal?: string;         // optional; used to auto-generate a system prompt

  tags?: string[];
  isDefault: boolean;
  isPublic: boolean;

  tools?: AgentTool[];
  subAgentIds?: string[];
  skills?: AgentSkill[];

  csatConfig: CSATConfig;        // REQUIRED
  handoffConfig: HandoffConfig;  // REQUIRED
  widgetConfig?: WidgetConfig;
  reactConfig?: ReactAgentConfig;
  userSuggestedActionsConfig?: UserSuggestedActionsConfig;
  contextConfig?: AgentContextConfig;

  configSchema: JSONSchema;      // REQUIRED (schema-driven config)
  config?: Record<string, any>;

  iconName?: string;             // Lucide icon name
  capabilities?: string[];
  samplePrompts?: string[];
  responseStyle?: string;
  personalityTraits?: string[];
  fallbackMessage?: string;
  responseDelay?: number; maxConcurrency?: number; sessionTimeout?: number;

  sourceTemplateId?: string; sourceTemplateVersion?: string;
  scope: AgentScopeTS;           // REQUIRED
  scopeId?: string;
  isFavorite: boolean;           // REQUIRED
  lastUsedAt?: string;
  usageCount: number;            // REQUIRED
  createdAt: string; updatedAt: string; createdBy: string; updatedBy: string;  // server-filled
  artifactVersion?: number;
  metadata?: Record<string, any>;
}

LLM knobs are temperature + maxTokens only — there is no top_p on Agent, ReactAgentConfig, or anywhere in these packages. ReAct agents carry a second copy of the LLM config inside reactConfig (provider, model, temperature, maxTokens + maxIterations, reasoningPrompt, …).

The skills array — AgentSkill (what's stored ON the agent)

Skills are two-layered: a catalog Skill (CRUD'd separately via useSkills) vs the per-agent AgentSkill[] embedded on agent.skills that references a catalog skill by skillId and carries per-agent overrides.

interface AgentSkill {
  skillId: string;                  // → a catalog Skill.id
  skillName?: string;               // denormalized for runtime perf
  title?: string; description?: string; category?: string;
  slashCommand?: string; iconName?: string;
  systemPromptExtension?: string;   // appended to the system prompt when this skill is active
  tools?: AgentSkillTool[];
  enabled: boolean;
  order: number;                    // 0 is valid
}
interface AgentSkillTool {
  toolId: string; toolName: string;
  title?: string; description?: string; type?: string;
  inputSchema?: JSONSchema;
  configSchema?: JSONSchema;        // what CAN be configured
  config?: Record<string, any>;     // actual configured VALUES
  enabled: boolean;
}

The standalone catalog Skill (CRUD via useSkills/api/v1/skills, keyed by name in v2 for update/delete): { id, name, title, category, slashCommand?, tools?: AgentTool[], systemPromptExtension?, configSchema?, persisted?, enabled, artifactVersion?, … }.

Tools, csat, handoff

interface AgentTool {
  toolId: string; toolName: string; title?: string; description?: string;
  type?: string;                    // document | search | api | extraction
  inputSchema?: JSONSchema; outputSchema?: JSONSchema; configSchema?: JSONSchema;
  config?: Record<string, any>; mergeConfig?: MergeConfig; enabled: boolean;
  isSystem?: boolean; order?: number;
}
interface CSATConfig { enabled: boolean; survey?: CSATSurvey; }
interface CSATSurvey {
  questions: CSATQuestion[]; timeThreshold: number; closeOnResponse: boolean;
}
interface HandoffConfig {
  enabled: boolean; triggerKeywords: string[]; queueId?: string; handoffMessage: string;
}

AgentTools on an agent are usually customized copies of a catalog ToolDefinition (the abstract template — CRUD via useToolDefinitions).


Saving a type-valid Agent object

createAgent/updateAgent take Partial<Agent>, so you don't supply server-managed fields (createdAt/updatedAt/createdBy/updatedBy, usageCount audit). The object below also satisfies the non-optional fields (csatConfig, handoffConfig, configSchema, scope, isFavorite, usageCount) so it's a fully-typed standalone Agent payload:

import type { Agent } from "@elqnt/agents/models";

const agent: Partial<Agent> = {
  orgId,
  product: "my-product",
  type: "chat",
  subType: "chat",
  name: "support_concierge",
  title: "Support Concierge",
  description: "Answers product questions and triages tickets.",
  status: "active",
  version: "1.0.0",

  // LLM config (temperature + maxTokens only — NO top_p)
  provider: "anthropic",
  model: "claude-sonnet-4-5",
  temperature: 0.4,
  maxTokens: 2048,
  systemPrompt: "You are a concise, friendly support concierge. Cite docs when relevant.",

  isDefault: false,
  isPublic: false,
  scope: "org",
  isFavorite: false,
  usageCount: 0,

  tools: [],
  subAgentIds: [],

  skills: [
    {
      skillId: "8f1c…",          // a real catalog Skill.id from useSkills().listSkills()
      skillName: "doc_search",
      title: "Document Search",
      enabled: true,
      order: 0,
      systemPromptExtension: "Use document search before answering factual questions.",
    },
  ],

  csatConfig:    { enabled: false },
  handoffConfig: { enabled: false, triggerKeywords: [], handoffMessage: "" },

  configSchema:  { type: "object", properties: {} },
  config:        {},

  iconName: "Bot",
  capabilities: ["Q&A", "Triage"],
  samplePrompts: ["What's your refund policy?", "Open a ticket for a broken item"],
};

const saved = await agents.createAgent(agent);     // → Agent (with id + audit fields)
if (saved) await agents.updateAgent(saved.id!, { temperature: 0.2 });

Hook: useAgents — the agent itself

import { useAgents } from "@elqnt/agents/hooks";

const agents = useAgents({ baseUrl, orgId, product: "my-product" });

Returns UseAgentsReturn = { loading, error, ...methods }:

MethodSignatureResolves toEndpoint
listAgents() => Promise<Agent[]>[] on errorGET /api/v1/agents
listAgentSummaries() => Promise<AgentSummary[]>[]GET /api/v1/agents/summary
getAgent(agentId: string) => Promise<Agent | null>nullGET /api/v1/agents/{agentId}
createAgent(agent: Partial<Agent>) => Promise<Agent | null>created agent (null on error)POST /api/v1/agents
updateAgent(agentId: string, agent: Partial<Agent>) => Promise<Agent | null>updated agent (null on error)PUT /api/v1/agents/{agentId}
deleteAgent(agentId: string) => Promise<boolean>true/falseDELETE /api/v1/agents/{agentId}
getDefaultAgent() => Promise<Agent | null>nullGET /api/v1/agents/default
exportAgent(agentId: string) => Promise<Agent | null>the Agent (null on error, non-throwing); side-effect: triggers a .agent.json download when an agent is returnedGET /api/v1/agents/{agentId}/export
importAgent(agent: Agent) => Promise<Agent | null>imported agent (null on error)POST /api/v1/agents/import

AgentSummary is the lightweight list shape ({ id, name, title, description?, iconName?, type, status, isDefault, isPublic, scope, provider?, model?, capabilities?, samplePrompts?, skills?, metadata? }) — use listAgentSummaries for pickers/lists, getAgent for the full object.

The raw API fns in @elqnt/agents/api mirror these (listAgentsApi, listAgentsSummaryApi, getAgentApi, createAgentApi, updateAgentApi, deleteAgentApi, getDefaultAgentApi, getAgentByNameApi(name, options)GET /api/v1/agents/by-name?name=, exportAgentApi, importAgentApi), each (...args, options: ApiClientOptions) => Promise<ApiResponse<T>>. Responses wrap as { agent } / { agents }.


Sibling hooks (catalog skills, tools, sub-agents, widgets)

All share the same { loading, error, ...methods } shape and the same imperative/non-throwing contract.

useSkills — catalog skills (UseSkillsReturn)

The standalone Skill catalog (keyed by name in v2). What an agent's AgentSkill[] references.

const skills = useSkills({ baseUrl, orgId, product: "my-product" });
MethodSignatureResolves toEndpoint
listSkills() => Promise<Skill[]>[]GET /api/v1/skills
getSkill(skillId: string) => Promise<Skill | null>nullGET /api/v1/skills/{skillId}
createSkill(skill: Partial<Skill>) => Promise<Skill | null>created (null on error)POST /api/v1/skills
updateSkill(skillId: string, skill: Partial<Skill>) => Promise<Skill | null>updated (null on error)PUT /api/v1/skills/by-name?name= (keyed by skill.name)
deleteSkill(skillId: string) => Promise<boolean>boolDELETE /api/v1/skills/by-name?name=
getCategories() => Promise<string[]>[]GET /api/v1/skills/categories

useToolDefinitions — abstract tool templates (UseToolDefinitionsReturn)

The ToolDefinition "templates" agents customize into AgentTools.

const tools = useToolDefinitions({ baseUrl, orgId, product: "my-product" });
MethodSignatureResolves toEndpoint
listToolDefinitions() => Promise<ToolDefinition[]>[]GET /api/v1/tool-definitions
getToolDefinition(toolDefId: string) => Promise<ToolDefinition | null>nullGET /api/v1/tool-definitions/{toolDefId}
getToolDefinitionsByIds(ids: string[]) => Promise<ToolDefinition[]>[]POST /api/v1/tool-definitions/by-ids body { ids }
createToolDefinition(td: Partial<ToolDefinition>) => Promise<ToolDefinition | null>created (null)POST /api/v1/tool-definitions body { toolDefinition }
updateToolDefinition(toolDefId: string, td: Partial<ToolDefinition>) => Promise<ToolDefinition | null>updated (null)PUT /api/v1/tool-definitions/{toolDefId} body { toolDefinition }
deleteToolDefinition(toolDefId: string) => Promise<boolean>boolDELETE /api/v1/tool-definitions/{toolDefId}

useSubAgents — sub-agents (UseSubAgentsReturn)

The SubAgents referenced by agent.subAgentIds.

const subAgents = useSubAgents({ baseUrl, orgId, product: "my-product" });
MethodSignatureResolves toEndpoint
listSubAgents() => Promise<SubAgent[]>[]GET /api/v1/subagents
getSubAgent(subAgentId: string) => Promise<SubAgent | null>nullGET /api/v1/subagents/{subAgentId}
createSubAgent(sa: Partial<SubAgent>) => Promise<SubAgent | null>created (null)POST /api/v1/subagents body { subAgent }
updateSubAgent(subAgentId: string, sa: Partial<SubAgent>) => Promise<SubAgent | null>updated (null)PUT /api/v1/subagents/{subAgentId} body { subAgent }
deleteSubAgent(subAgentId: string) => Promise<boolean>boolDELETE /api/v1/subagents/{subAgentId}

useWidgets — embeddable chat widgets for one agent (UseWidgetsReturn)

The only sibling whose options carry an agentId — the agent is fixed at hook creation (agent.widgetConfig is the inline copy; these are the stored AgentWidgets).

const widgets = useWidgets({ baseUrl, orgId, product: "my-product", agentId });
MethodSignatureResolves toEndpoint
listWidgets() => Promise<AgentWidget[]>[]GET /api/v1/agents/{agentId}/widgets
getWidget(widgetId: string) => Promise<AgentWidget | null>nullGET /api/v1/widgets/{widgetId}
getDefaultWidget() => Promise<AgentWidget | null>nullGET /api/v1/agents/{agentId}/widgets/default
createWidget(w: Partial<AgentWidget>) => Promise<AgentWidget | null>created (null)POST /api/v1/agents/{agentId}/widgets body { widget }
updateWidget(widgetId: string, w: Partial<AgentWidget>) => Promise<AgentWidget | null>updated (null)PUT /api/v1/widgets/{widgetId} body { widget }
deleteWidget(widgetId: string) => Promise<boolean>boolDELETE /api/v1/widgets/{widgetId}
setDefaultWidget(widgetId: string) => Promise<boolean>boolPOST /api/v1/widgets/{widgetId}/default body { agentId }

Other resource hooks (all export a named Use<Name>Return)

Beyond the five detailed above, every resource hook in this package now exports its exact, named return interface from @elqnt/agents/hooks — the .d.ts is the contract. All follow the same imperative, non-throwing shape ({ loading, error, ...methods }), except useBackgroundAgents, which is a realtime hook that additionally holds SSE stream state (liveStates, watchedChats).

HookNamed returnNotes
useAgentJobsUseAgentJobsReturnPostgreSQL-backed job CRUD + pause/resume
useAnalyticsUseAnalyticsReturnagent chats / CSAT / list / task-outcomes (defaults [])
useIntegrationsUseIntegrationsReturnOAuth connect/disconnect/refresh/triage
useSandboxUseSandboxReturnAI-generated HTML sandboxes
useSchedulerTasksUseSchedulerTasksReturnone-off scheduler tasks
useSchedulerSchedulesUseSchedulerSchedulesReturnrecurring schedules
useSkillUserConfigUseSkillUserConfigReturnper-user/per-agent skill config
useStructuredOutput<T>UseStructuredOutputReturn<T>one-shot structured LLM output (runT | null)
useImageGenerationUseImageGenerationReturnone-shot image generate/edit (generate → hosted URL | null); providers fal (fast: ~1-2s flux/schnell, edit: nano-banana-2) / azure-openai / gemini; curated ids in IMAGE_MODELS
useBackgroundAgentsUseBackgroundAgentsReturnrealtime — SSE stream + watched-chat state

The raw hook (reference only — wrap it)

This is the bare useAgents hook, shown so the spec above is concrete. It's fine for a one-off spike, but a real app must import @elqnt/agents in exactly one app hook and let components consume it via a context. baseUrl/orgId come from useAppConfig() (fed by SSR), never a NEXT_PUBLIC_* var.

"use client";
import { useAgents } from "@elqnt/agents/hooks";
import { useEffect, useState } from "react";
import type { Agent } from "@elqnt/agents/models";
import { useAppConfig } from "@/contexts/app-config-context";

export function AgentList() {
  const { apiGatewayUrl, orgId } = useAppConfig();
  const agents = useAgents({ baseUrl: apiGatewayUrl, orgId, product: "my-product" });

  const [rows, setRows] = useState<Agent[]>([]);

  useEffect(() => {
    agents.listAgents().then(setRows);
  }, []); // eslint-disable-line

  async function createDraft() {
    const created = await agents.createAgent({
      orgId, product: "my-product",
      type: "chat", subType: "chat",
      name: "support_concierge", title: "Support Concierge",
      status: "draft", version: "1.0.0",
      provider: "anthropic", model: "claude-sonnet-4-5",
      temperature: 0.4, maxTokens: 2048,
      systemPrompt: "You are a friendly support concierge.",
      isDefault: false, isPublic: false, scope: "org",
      isFavorite: false, usageCount: 0,
      csatConfig: { enabled: false },
      handoffConfig: { enabled: false, triggerKeywords: [], handoffMessage: "" },
      configSchema: { type: "object", properties: {} },
    });
    if (created) setRows((r) => [created, ...r]);
  }

  if (agents.error) return <p>Error: {agents.error}</p>;
  return (
    <div>
      <button onClick={createDraft} disabled={agents.loading}>New</button>
      <ul>{rows.map((a) => <li key={a.id}>{a.title}</li>)}</ul>
    </div>
  );
}

The wrap point (context)

// contexts/agents-context.tsx
"use client";
import { createContext, useContext, type ReactNode } from "react";
import { useAgents, type UseAgentsReturn } from "@elqnt/agents/hooks";
import { useAppConfig } from "@/contexts/app-config-context";

const AgentsContext = createContext<UseAgentsReturn | undefined>(undefined);

export function AgentsProvider({ children }: { children: ReactNode }) {
  const { apiGatewayUrl, orgId } = useAppConfig();
  const value = useAgents({ baseUrl: apiGatewayUrl, orgId, product: "my-product" });
  return <AgentsContext.Provider value={value}>{children}</AgentsContext.Provider>;
}

export function useAgentsContext(): UseAgentsReturn {
  const ctx = useContext(AgentsContext);
  if (!ctx) throw new Error("useAgentsContext must be used within AgentsProvider");
  return ctx;
}

AppConfigProvider (the { apiGatewayUrl, orgId } SSR config context) is the same one the entities skill defines — read API_GATEWAY_URL_PUBLIC server-side at request time in the layout and forward it through the provider; do not use a NEXT_PUBLIC_* var (those bake the build-env URL into the bundle forever).


Provisioning agents (admin / bulk)

For seeding platform-default agents (with their tools/sub-agents/skills) rather than one-off CRUD:

import { provisionAgentsApi } from "@elqnt/agents/api";
import type { DefaultDefinitions } from "@elqnt/agents/models";

await provisionAgentsApi(defs /* DefaultDefinitions */, { baseUrl, orgId });

Gotchas

  • Two packages, two auth models. Design/save = @elqnt/agents (this skill, bearer JWT, baseUrl = gateway root). Chatting = @elqnt/chat (separate SKILL.md; realtime uses an origin allow-list, no bearer, baseUrl = .../api/v1/chat). Don't conflate them.
  • LLM knobs are temperature + maxTokens onlytop_p does not exist in these types. Don't add it.
  • Methods don't throwincluding exportAgent. They return defaults ([], null, false) and set error. Check error / null results; don't wrap in try/catch expecting throws. exportAgent returns Agent | null (null on failure) and triggers a .agent.json browser download as a documented side-effect when an agent is returned.
  • Create/update take Partial<Agent> — don't send audit fields (createdAt/createdBy/…); the backend fills them. But the required fields (csatConfig, handoffConfig, configSchema, scope, isFavorite, usageCount) must be present for a fully-typed standalone Agent.
  • Skills are two-layered — catalog Skill (CRUD via useSkills, keyed by name in v2 for update/delete) vs per-agent AgentSkill[] on agent.skills (references skillId, carries enabled/order/overrides). To pick up later changes to a catalog skill's configSchema, re-attach the skill on the agent — it's snapshotted at attach time.
  • useWidgets needs agentId in options — unlike the other sibling hooks.
  • The package has no provider. Options go into every hook call — which is why your app builds an AppConfigProvider (inject baseUrl/orgId once via SSR) and wraps the hook in one context, so components and the app hook never re-thread config by hand.
  • Components don't need a translator layer. Agent/AgentSummary are already rich domain types — speak them directly. (Only the single wrap point — one hook import behind a context — is mandatory, not translators.)
  • product consistency — set the same product in the JWT claim and the hook options, or you'll read/write the wrong product's agents.
  • Bumping a platform-shipped agent? Bump its artifactVersion in the registry so the reconciler rolls the change out to existing orgs (see skill 29-data-changes §C). This is separate from per-org CRUD.

Related skills

  • @elqnt/chatSKILL.mdchat with the agent you saved here (POST + SSE; bind the thread via startChat({ agentId })).
  • entities (@elqnt/entitySKILL.md) — drive the entities backend the same way (hooks → gateway).