@ego-z/client
v1.6.0
Published
Official EgoZ client SDK for Node.js - LLM orchestration made easy
Maintainers
Readme
@ego-z/client
Official EgoZ Client SDK for Node.js - LLM orchestration made easy.
Installation
npm install @ego-z/clientQuick Start
import { EgoZ } from '@ego-z/client';
const egoz = new EgoZ({
apiKey: process.env.EGOZ_API_KEY, // egoz_live_xxx or egoz_test_xxx
tenantId: process.env.EGOZ_TENANT_ID,
});
const response = await egoz.ask({
message: 'What is the refund policy?',
});
console.log(response.answer);
console.log(`Tokens used: ${response.usage.totalTokens}`);Features
- Simple API - One method to interact with your AI assistant
- Streaming - Token-by-token SSE via callback (
askStream) orfor-await(askStreamIter) - Trusted-header passthrough - Per-request
headersfield flows into EgoZ's per-toolforwardHeadersallowlist - TypeScript First - Full type definitions included
- Automatic Retries - Built-in retry logic with exponential backoff
- Webhook Verification - Secure your tool endpoints with signature validation
- Express Middleware - Drop-in middleware for webhook verification
Configuration
const egoz = new EgoZ({
apiKey: 'egoz_live_xxx', // Required - Your EgoZ API key
tenantId: 'your-tenant-id', // Required - Your tenant/project ID
timeout: 30000, // Optional - Request timeout (ms)
retries: 2, // Optional - Number of retry attempts
});Sending Messages
Basic Message
const response = await egoz.ask({
message: 'Hello, how can you help me?',
});With Conversation Threading
// Start a new conversation
const first = await egoz.ask({
message: 'What is your return policy?',
externalUserId: 'user-123', // Your user's ID
});
// Continue the conversation
const second = await egoz.ask({
message: 'What about electronics?',
threadId: first.threadId, // Continue same thread
externalUserId: 'user-123',
});Bring Your Own Conversation ID (externalThreadId)
If your app already has its own notion of a conversation (e.g.
conv:abc123, thread:user42:session99), send it as externalThreadId
and EgoZ will resolve every subsequent call with the same id to the
same thread — no need to round-trip the EgoZ UUID.
// First call — YOUR conversation id, no need to track EgoZ's UUID.
const r1 = await egoz.ask({
message: 'Hello',
externalThreadId: 'conv:abc123', // YOUR id
externalUserId: 'user:42',
});
// Later — same externalThreadId resolves to the same EgoZ thread.
const r2 = await egoz.ask({
message: 'Continue please',
externalThreadId: 'conv:abc123',
});
// r1.threadId === r2.threadId (the EgoZ UUID is stable across the conversation)
// r1.externalThreadId === 'conv:abc123' (echoed back so you can verify)Failure modes (Phase 6 — explicit on both fields):
| You send | Behaviour |
|---|---|
| externalThreadId: 'conv:abc123' (first time) | New thread is created with this id attached. |
| externalThreadId: 'conv:abc123' (subsequent) | Same thread is resolved every time. |
| threadId: '<unknown EgoZ UUID>' | 404 NotFound — no silent new thread. |
| threadId: '<not a UUID>' | 400 InvalidInput — use externalThreadId for your own ids. |
| Both threadId and externalThreadId | threadId wins for back-compat; debug warning logged. |
externalThreadId shape rules: any string up to 255 characters, opaque
to EgoZ (never parsed). Uniqueness is enforced per-tenant.
With Auth Token Forwarding
// Forward your user's auth token to your tools
const response = await egoz.ask({
message: 'Check my order status',
authToken: 'user-jwt-token', // Forwarded to your tool endpoints
});With Custom Metadata
const response = await egoz.ask({
message: 'Process refund for my order',
metadata: {
orderId: 'order-456',
customerId: 'cust-789',
internalTenantId: 'tenant-abc', // For multi-tenant setups
},
});With Per-Request Headers (forwardHeaders passthrough)
Pass extra HTTP headers on a single /ask call. EgoZ captures every
inbound header and forwards any name listed in the target tool's
forwardHeaders allowlist verbatim to that tool's endpoint. Use this
for trusted, server-set context — locale, app version, the
authenticated user/tenant id when proxying through a gateway, A/B
bucket, etc.
// Server-side (e.g. inside a GraphQL resolver) — caller already authenticated
const response = await egoz.ask({
message: 'Show my recent orders',
externalUserId: ctx.user.id,
headers: {
'x-user-id': ctx.user.id, // trusted: injected from session
'x-tenant-id': ctx.user.tenantId, // (your app's tenant, not EgoZ's)
'x-locale': ctx.user.locale,
'x-app-version': req.get('x-app-version') ?? 'unknown',
},
});For these to actually reach the upstream tool, configure the tool's
forwardHeaders allowlist in the EgoZ console (or via MCP) to include
the names you're sending.
SDK-reserved names are silently stripped: Authorization,
Content-Type, Accept, X-API-Key, X-Tenant-Id, X-Request-Id,
plus HTTP framing headers (Host, Content-Length,
Transfer-Encoding, Connection). Use authToken instead of
Authorization. The exhaustive list is exported as
SDK_RESERVED_HEADERS if you want to validate ahead of time.
Forwarding per-call secrets to tools (toolHeaders)
The HTTP headers channel above is the right fit for trusted,
server-set context the SDK can pass through verbatim. It's the
wrong channel when the value:
- is per-end-user (so it can't be baked into the tool's static
headersconfig in the console), AND - shares a name with a header EgoZ itself uses to authenticate the
caller — most commonly
AuthorizationandX-API-Key.
A literal Authorization: Bearer <user-token> sent on the HTTP
request would clash with the SDK's own auth (and is stripped from the
inbound forward path defensively, regardless). The body field
toolHeaders is the explicit opt-in for that case:
const response = await egoz.ask({
message: 'Show my recent invoices',
externalUserId: ctx.user.id,
toolHeaders: {
// Per-end-user secret destined for the tool, NOT for EgoZ.
Authorization: `Bearer ${ctx.user.tenantApiToken}`,
// Tenant's own service-to-service key — also reserved by EgoZ
// on the HTTP path, so it goes here instead.
'X-API-Key': ctx.user.tenantServiceKey,
},
});How it flows:
- The SDK puts
toolHeaderson the/askrequest body, not the HTTP headers, so it can't collide with EgoZ-platform credentials. - EgoZ validates the map (RFC 7230 names, value byte budget,
MAX_FORWARD_HEADERScount, no CR/LF/NUL, no EgoZ-internal namespaces) and rejects malformed input with400 INVALID_INPUT. - For each tool the LLM ends up calling, EgoZ picks names that appear
on both
toolHeadersand that tool'sforwardHeadersallowlist. Body values win over inbound-HTTP forwards on name conflicts. - Static tool config (
tool.headers) andauthType: 'FORWARD'still override on top of forwarded values — tool owners stay in control of the final outbound request.
When debug mode is on (console only), the tool.debug SSE frame
includes forwardedHeaderSources: { name: 'body' | 'inbound' } so you
can see which channel actually delivered each header without the
backend re-emitting the value.
Letting your users pick the model (listModels)
A project runs one chat model by default. If its owner turns on model switching in the EgoZ console and curates a list, each call may name a model from that list — so you can offer a model picker without the owner handing out a second project.
const menu = await egoz.listModels();
if (menu.switchable) {
renderPicker(menu.models, menu.defaultId);
// [ { id: 'openai:gpt-4o', provider: 'openai', model: 'gpt-4o', isDefault: true },
// { id: 'anthropic:claude-sonnet-4', provider: 'anthropic', model: 'claude-sonnet-4', isDefault: false } ]
}
const response = await egoz.ask({
message: 'Summarize this thread',
model: chosenId, // an `id` from menu.models — echo it back verbatim
});Read the menu, don't hardcode it. The list is one a project owner edits in the console, without telling you. A hardcoded copy is a picker that keeps offering a model the project dropped — every selection 400s, in your deployment, with nothing on the console side to suggest anything broke.
Things worth knowing before you build the picker:
listModels()is not cached. Deliberately: a cache inside the SDK would put a staleness window in exactly the place this call exists to remove, and only you know how long your surface can hold a menu. Cache it yourself, and re-read when a turn comes backMODEL_NOT_ALLOWED.switchable: falsestill returns one entry (the project default), so a picker renders the same way either way and simply has nothing to switch to. You never have to special-case an empty list.- Omit
modeland nothing changes — the project default runs, exactly as it always has. - A rejected model is a
400, never a silent fallback. You getMODEL_NOT_ALLOWEDwhen the project has switching off, or when the id isn't on its menu — usually because the owner edited the list after your picker cached it. Re-read the menu and re-render; don't retry the same body. 'gpt-4o-mini'works too, as long as only one provider on that project offers it. The qualifiedidis refused only when it would be a coin flip.- Retrieval is unaffected. Embeddings stay pinned to the project — the knowledge base was indexed with one embedding model, and querying it with another returns confident nonsense. Only the answering model moves.
- Voice calls ignore it. A live call keeps the project's fast voice model whatever you send, because a call is a latency budget.
The model that actually answered is on every response as response.model
(and on the streaming done event) — use that, not your own picker state, if
you want to display which model produced a reply.
Streaming
EgoZ streams events as it thinks, retrieves knowledge, calls tools, and generates text. Two consumption styles, pick whichever fits the call site.
Callback style (askStream)
await egoz.askStream(
{
message: 'Walk me through resetting my password.',
headers: { 'x-locale': 'en-US' },
},
{
onEvent: (event) => {
if (event.type === 'delta') process.stdout.write(event.content);
if (event.type === 'tool.calling') console.log('\n→', event.name);
if (event.type === 'done') console.log('\n[done]', event.usage);
},
},
);Async-iterator style (askStreamIter)
Designed for GraphQL Subscription resolvers and any place where
for await is more natural than registering a callback. Breaking out
of the loop aborts the underlying SSE connection.
for await (const ev of egoz.askStreamIter({
message: 'Walk me through resetting my password.',
headers: { 'x-locale': 'en-US' },
})) {
switch (ev.type) {
case 'delta': yield { token: ev.content }; break;
case 'tool.calling': yield { toolStart: ev.name }; break;
case 'done': yield { final: ev }; return;
case 'error': throw new Error(ev.message);
}
}Pass an AbortSignal for external cancellation (e.g. WebSocket close,
HTTP request aborted by the client):
const ctrl = new AbortController();
req.on('close', () => ctrl.abort());
for await (const ev of egoz.askStreamIter(req.body, { signal: ctrl.signal })) {
// ...
}Response Object
interface AskResponse {
threadId: string; // Conversation thread ID
answer: string; // AI's response text
intent: 'vanilla' | 'rag' | 'tools' | 'rag_tools';
toolUsed: string | null; // Tool name if one was called
ragChunksUsed: number; // Number of knowledge chunks used
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
model: string; // Model used (e.g., 'gpt-4.1')
}Webhook Signature Verification
When EgoZ calls your tool endpoints, it signs the request. Verify these signatures to ensure requests are authentic.
Using Express Middleware
import express from 'express';
import { createEgoZMiddleware } from '@ego-z/client/express';
const app = express();
app.use(express.json());
// Create middleware with your webhook secret
const verifyEgoZ = createEgoZMiddleware({
secret: process.env.EGOZ_WEBHOOK_SECRET,
tolerance: 300, // Optional: max age in seconds (default: 5 min)
});
// Apply to your tool routes
app.post('/api/tools/check-order', verifyEgoZ, (req, res) => {
// Request is verified - access metadata
console.log(req.egoz?.requestId); // EgoZ request ID
console.log(req.egoz?.tenantId); // Tenant ID
// Your tool logic...
res.json({ status: 'Order shipped' });
});Manual Verification
import { verifySignature, validateSignature } from '@ego-z/client';
// Option 1: Throws on invalid signature
try {
verifySignature({
signature: req.headers['x-egoz-signature'],
timestamp: req.headers['x-egoz-timestamp'],
body: JSON.stringify(req.body),
secret: process.env.EGOZ_WEBHOOK_SECRET,
});
// Signature is valid
} catch (error) {
// Signature is invalid
return res.status(401).json({ error: 'Invalid signature' });
}
// Option 2: Returns boolean
const isValid = validateSignature({
signature: req.headers['x-egoz-signature'],
timestamp: req.headers['x-egoz-timestamp'],
body: JSON.stringify(req.body),
secret: process.env.EGOZ_WEBHOOK_SECRET,
});Error Handling
import {
EgoZError,
AuthenticationError,
ValidationError,
NetworkError,
TimeoutError,
RateLimitError,
} from '@ego-z/client';
try {
const response = await egoz.ask({ message: 'Hello' });
} catch (error) {
if (error instanceof AuthenticationError) {
console.log('Invalid API key');
} else if (error instanceof ValidationError) {
console.log('Invalid request:', error.message);
} else if (error instanceof RateLimitError) {
console.log(`Rate limited. Retry after ${error.retryAfter}s`);
} else if (error instanceof TimeoutError) {
console.log('Request timed out');
} else if (error instanceof NetworkError) {
console.log('Network error:', error.message);
} else if (error instanceof EgoZError) {
console.log(`API error: ${error.code} - ${error.message}`);
}
}Health Check
const isHealthy = await egoz.ping();
if (!isHealthy) {
console.log('EgoZ API is not reachable');
}Headers Sent by EgoZ
When EgoZ calls your tool endpoints, it includes these headers:
| Header | Description |
|--------|-------------|
| X-EgoZ-Signature | HMAC-SHA256 signature (sha256=...) |
| X-EgoZ-Timestamp | Unix timestamp of the request |
| X-EgoZ-Request-Id | Unique request ID for debugging |
| X-EgoZ-Tenant-Id | Your tenant/project ID |
TypeScript
Full TypeScript support with exported types:
import type {
EgoZConfig,
AskRequest,
AskResponse,
AskStreamRequest,
AskStreamEvent,
SignatureParams,
ExpressMiddlewareOptions,
} from '@ego-z/client';Wire-level types (AskRequest, AskResponse, AskStreamEvent, …) are
sourced from @ego-z/contracts — the same package the EgoZ backend
imports — so your decoded responses are guaranteed to match what the
server actually sends. No more silent answer === undefined from a
field-name drift between SDK and backend.
End-to-End Smoke Tests
The SDK ships an opt-in smoke suite that hits a real EgoZ backend over
HTTP. Set the three env vars below and run npm run test:smoke to
verify your /ask and /ask/stream paths end-to-end against your own
project:
EGOZ_SMOKE_BASE_URL=http://localhost:3000 \
EGOZ_SMOKE_API_KEY=egoz_test_xxxxxxxxxxxxxxxx \
EGOZ_SMOKE_TENANT_ID=00000000-0000-0000-0000-000000000000 \
npm run test:smokeWhen the env isn't set the suite skips cleanly — npm test never tries
to talk to a live service.
