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 ownSKILL.md). Bind the two by passing{ agentId }(or{ agentName }) when you start a chat thread; this skill does not cover chat. Cross-link:@elqnt/chat→SKILL.md.
| Concern | Package | Backend | Gateway prefix | Auth |
|---|---|---|---|---|
| Design/save an agent (this skill) | @elqnt/agents | backend/services/agents | /api/v1/agents/… | @elqnt/api-client auto-JWT (bearer) |
| Chat with it (separate skill) | @elqnt/chat | backend/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/agents → 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 { useAgents, useSkills, useToolDefinitions, useSubAgents, useWidgets } from "@elqnt/agents/hooks"— the design-time hooks.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.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):
- 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. - 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 hooks. No
fetch/axiosto/api/v1/agents/..., no NATS, no direct service calls. (Server-side only:@elqnt/agents/api+createServerClient, same signatures.) - 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. - 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
EntityRecordis a generic{ id, name, fields }envelope that must be wrapped in translators), anAgentis already a rich, camelCase domain type. There's nothing to unwrap. So a translator layer is usually overkill — components can speakAgent/AgentSummarydirectly. Still keep the single wrap point:@elqnt/agentsis 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 thanAgent.
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 → 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 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-Productfrom the signed JWT claims before proxying, so a forged header can't override the token — the JWT is authoritative. Routes declare requiredscopes(e.g.write:agents); the check is an OR match, andadmin/super_adminrole 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 orcreateServerClientdirectly — 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 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
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
useApiAsyncand returns anexecutefunction. There is nodata/auto-loadingper call — youawait listAgents()to get data; the hook exposes aggregateloadinganderrorflags. On failure a method returns its default ([],null,false) and setserror— it does not throw.exportAgentfollows the same no-throw contract: it returns the exportedAgent, ornullon failure (witherrorset), and as a documented side-effect triggers a.agent.jsonbrowser 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+maxTokensonly — there is notop_ponAgent,ReactAgentConfig, or anywhere in these packages. ReAct agents carry a second copy of the LLM config insidereactConfig(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 }:
| Method | Signature | Resolves to | Endpoint |
|---|---|---|---|
listAgents | () => Promise<Agent[]> | [] on error | GET /api/v1/agents |
listAgentSummaries | () => Promise<AgentSummary[]> | [] | GET /api/v1/agents/summary |
getAgent | (agentId: string) => Promise<Agent | null> | null | GET /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/false | DELETE /api/v1/agents/{agentId} |
getDefaultAgent | () => Promise<Agent | null> | null | GET /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 returned | GET /api/v1/agents/{agentId}/export |
importAgent | (agent: Agent) => Promise<Agent | null> | imported agent (null on error) | POST /api/v1/agents/import |
AgentSummaryis the lightweight list shape ({ id, name, title, description?, iconName?, type, status, isDefault, isPublic, scope, provider?, model?, capabilities?, samplePrompts?, skills?, metadata? }) — uselistAgentSummariesfor pickers/lists,getAgentfor 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" });
| Method | Signature | Resolves to | Endpoint |
|---|---|---|---|
listSkills | () => Promise<Skill[]> | [] | GET /api/v1/skills |
getSkill | (skillId: string) => Promise<Skill | null> | null | GET /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> | bool | DELETE /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" });
| Method | Signature | Resolves to | Endpoint |
|---|---|---|---|
listToolDefinitions | () => Promise<ToolDefinition[]> | [] | GET /api/v1/tool-definitions |
getToolDefinition | (toolDefId: string) => Promise<ToolDefinition | null> | null | GET /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> | bool | DELETE /api/v1/tool-definitions/{toolDefId} |
useSubAgents — sub-agents (UseSubAgentsReturn)
The SubAgents referenced by agent.subAgentIds.
const subAgents = useSubAgents({ baseUrl, orgId, product: "my-product" });
| Method | Signature | Resolves to | Endpoint |
|---|---|---|---|
listSubAgents | () => Promise<SubAgent[]> | [] | GET /api/v1/subagents |
getSubAgent | (subAgentId: string) => Promise<SubAgent | null> | null | GET /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> | bool | DELETE /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 });
| Method | Signature | Resolves to | Endpoint |
|---|---|---|---|
listWidgets | () => Promise<AgentWidget[]> | [] | GET /api/v1/agents/{agentId}/widgets |
getWidget | (widgetId: string) => Promise<AgentWidget | null> | null | GET /api/v1/widgets/{widgetId} |
getDefaultWidget | () => Promise<AgentWidget | null> | null | GET /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> | bool | DELETE /api/v1/widgets/{widgetId} |
setDefaultWidget | (widgetId: string) => Promise<boolean> | bool | POST /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).
| Hook | Named return | Notes |
|---|---|---|
useAgentJobs | UseAgentJobsReturn | PostgreSQL-backed job CRUD + pause/resume |
useAnalytics | UseAnalyticsReturn | agent chats / CSAT / list / task-outcomes (defaults []) |
useIntegrations | UseIntegrationsReturn | OAuth connect/disconnect/refresh/triage |
useSandbox | UseSandboxReturn | AI-generated HTML sandboxes |
useSchedulerTasks | UseSchedulerTasksReturn | one-off scheduler tasks |
useSchedulerSchedules | UseSchedulerSchedulesReturn | recurring schedules |
useSkillUserConfig | UseSkillUserConfigReturn | per-user/per-agent skill config |
useStructuredOutput<T> | UseStructuredOutputReturn<T> | one-shot structured LLM output (run → T | null) |
useImageGeneration | UseImageGenerationReturn | one-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 |
useBackgroundAgents | UseBackgroundAgentsReturn | realtime — 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(separateSKILL.md; realtime uses an origin allow-list, no bearer,baseUrl=.../api/v1/chat). Don't conflate them. - LLM knobs are
temperature+maxTokensonly —top_pdoes not exist in these types. Don't add it. - Methods don't throw — including
exportAgent. They return defaults ([],null,false) and seterror. Checkerror/ null results; don't wrap in try/catch expecting throws.exportAgentreturnsAgent | null(nullon failure) and triggers a.agent.jsonbrowser 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 standaloneAgent. - Skills are two-layered — catalog
Skill(CRUD viauseSkills, keyed by name in v2 for update/delete) vs per-agentAgentSkill[]onagent.skills(referencesskillId, carriesenabled/order/overrides). To pick up later changes to a catalog skill'sconfigSchema, re-attach the skill on the agent — it's snapshotted at attach time. useWidgetsneedsagentIdin 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(injectbaseUrl/orgIdonce 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/AgentSummaryare already rich domain types — speak them directly. (Only the single wrap point — one hook import behind a context — is mandatory, not translators.) productconsistency — set the sameproductin the JWT claim and the hook options, or you'll read/write the wrong product's agents.- Bumping a platform-shipped agent? Bump its
artifactVersionin the registry so the reconciler rolls the change out to existing orgs (see skill29-data-changes§C). This is separate from per-org CRUD.
Related skills
@elqnt/chat→SKILL.md— chat with the agent you saved here (POST + SSE; bind the thread viastartChat({ agentId })).entities(@elqnt/entity→SKILL.md) — drive the entities backend the same way (hooks → gateway).