Alchemist: Embedded Agent — Backend Client
Call a bound Chipp agent from your application server for search, recommendations, or autonomous workflows.
The backend client lets your application server invoke a bound Chipp agent programmatically. Use it to power search features, generate recommendations, automate workflows, or any server-side task that benefits from an AI agent.
Unlike the frontend UI (inline panel or widget), the backend client is never exposed to the browser. Your server controls the request, manages timeouts and retries, and processes the response.
Installation
The embedded-Chipp-agent feature pack includes the backend client library. Install it in your project:
npm install @chipp/embed-client
# or
deno add npm:@chipp/embed-clientQuick start
1. Initialize the client
In your backend (routes, jobs, or services):
import { ChippEmbedServerClient } from '@chipp/embed-client/server';
const client = new ChippEmbedServerClient({
// Your Alchemist project endpoint (e.g., https://api.example.com)
baseUrl: process.env.ALCHEMIST_API_URL,
// Your project's client key (never expose to the browser)
projectClientKey: process.env.ALCHEMIST_PROJECT_CLIENT_KEY,
});2. Make a request
Non-streaming (wait for complete response):
const response = await client.sendMessageAndWait({
// Required: user's identity
userId: 'user@example.com', // Authenticated email
// OR
// anonymousSessionId: 'anon_...', // Anonymous visitor (from frontend)
// Required: the message
message: 'What are the trending products this week?',
// Optional: conversation context
threadId: 'existing-thread-id', // Resume a conversation
// Optional: metadata
metadata: { source: 'search-widget', user_plan: 'pro' },
// Optional: timeout
timeout: 30_000, // 30 seconds
});
console.log(response.message); // Agent's reply
console.log(response.threadId); // For resuming laterStreaming (receive response tokens in real-time):
const stream = await client.sendMessage({
userId: 'user@example.com',
message: 'Summarize my last 10 orders',
});
for await (const event of stream) {
if (event.type === 'message_chunk') {
process.stdout.write(event.text);
} else if (event.type === 'tool_call') {
console.log('Agent called tool:', event.tool, event.input);
} else if (event.type === 'citation') {
console.log('Citation:', event.source, event.text);
}
}Client initialization options
interface ChippEmbedServerClientConfig {
// Required
baseUrl: string; // Alchemist project API URL
projectClientKey: string; // Project client key (alch_proj_...)
// Optional
timeout?: number; // Default request timeout in ms (default: 30000)
retryAttempts?: number; // Max retry attempts for transient errors (default: 3)
retryDelay?: number; // Base retry delay in ms (default: 1000)
retryJitter?: boolean; // Add jitter to retry delays (default: true)
}Request options
interface SendMessageOptions {
// Identity: exactly one of these
userId?: string; // Authenticated user email
anonymousSessionId?: string; // Anonymous session ID
externalTenantId?: string; // Multi-tenant context
// Required
message: string; // The user's message
// Optional
threadId?: string; // Existing conversation to continue
metadata?: Record<string, unknown>; // Context (source, plan, etc.)
clientMessageId?: string; // For idempotency
timeout?: number; // Override default timeout
signal?: AbortSignal; // Cancellation signal
}Non-streaming response
interface SendMessageAndWaitResponse {
threadId: string; // Conversation ID
message: string; // Agent's reply
citations?: Citation[]; // Sources cited
toolCalls?: ToolCall[]; // Tools executed
files?: FileAttachment[]; // Files returned by agent
metadata?: Record<string, unknown>; // Agent-supplied context
}Streaming response
When you stream, you receive a series of events:
type StreamEvent =
| { type: 'message_chunk'; text: string }
| { type: 'message_start'; threadId: string }
| { type: 'message_complete'; message: string }
| { type: 'tool_call'; tool: string; input: unknown; callId: string }
| { type: 'tool_result'; callId: string; result: string }
| { type: 'citation'; source: string; text: string; url?: string }
| { type: 'file'; filename: string; mimeType: string; data: string }
| { type: 'error'; error: string };Example stream processor:
const stream = await client.sendMessage({
userId: 'user@example.com',
message: 'Search for customer complaints',
});
let fullMessage = '';
const tools: Array<{ tool: string; input: unknown }> = [];
const citations: Array<{ source: string; text: string }> = [];
for await (const event of stream) {
switch (event.type) {
case 'message_chunk':
fullMessage += event.text;
break;
case 'tool_call':
tools.push({ tool: event.tool, input: event.input });
break;
case 'citation':
citations.push({ source: event.source, text: event.text });
break;
case 'error':
console.error('Stream error:', event.error);
break;
}
}
return { message: fullMessage, tools, citations };Identity management
Authenticated users
For authenticated end-users, pass their email:
const response = await client.sendMessageAndWait({
userId: 'alice@example.com', // ← Email is the unique ID
message: 'Show my account activity',
});Important: The userId should be stable and unique per user (typically an email or internal ID). Conversations are attributed to this ID; changing it creates a new conversation.
Anonymous/visitor sessions
For unauthenticated visitors, use their session ID (from the frontend):
// From your frontend (stored in browser)
const sessionId = getStoredAnonymousSessionId();
const response = await client.sendMessageAndWait({
anonymousSessionId: sessionId,
message: 'Help me find products',
});The backend client never creates anonymous sessions; the frontend must mint them via the session endpoint.
Multi-tenant context
For multi-tenant applications, optionally pass tenant context:
const response = await client.sendMessageAndWait({
userId: 'user@example.com',
externalTenantId: 'tenant_123', // Optional, for context
message: 'Generate a report for this tenant',
metadata: { tenant_id: 'tenant_123', tenant_plan: 'enterprise' },
});Retries & error handling
The client automatically retries transient errors (network timeouts, 5xx responses, etc.). Customize retry behavior:
const client = new ChippEmbedServerClient({
baseUrl: process.env.ALCHEMIST_API_URL,
projectClientKey: process.env.ALCHEMIST_PROJECT_CLIENT_KEY,
retryAttempts: 5, // Max retries
retryDelay: 500, // Initial delay in ms
retryJitter: true, // Add randomness to avoid thundering herd
});Handling errors
All errors are normalized to EmbedServerClientError:
import { EmbedServerClientError } from '@chipp/embed-client/server';
try {
const response = await client.sendMessageAndWait({
userId: 'user@example.com',
message: 'Help me',
});
} catch (error) {
if (error instanceof EmbedServerClientError) {
console.error('Code:', error.code); // AUTH_FAILED, TIMEOUT, etc.
console.error('Message:', error.message); // Human-readable
console.error('Retryable:', error.retryable); // Should we retry?
console.error('Status:', error.statusCode); // HTTP status, if applicable
} else {
// Unknown error
throw error;
}
}Error codes
| Code | Meaning | Retryable |
|---|---|---|
AUTH_FAILED | Invalid client key or session | No |
SESSION_EXPIRED | Session TTL exceeded | No |
UNAUTHORIZED | User not authorized for this binding | No |
TIMEOUT | Request exceeded timeout | Yes |
NETWORK_ERROR | Connection failed | Yes |
SERVICE_UNAVAILABLE | Agent or backend is down | Yes |
RATE_LIMITED | Too many requests | Yes |
VALIDATION_ERROR | Invalid input (empty message, etc.) | No |
Cancellation & timeouts
Custom timeout per request
const response = await client.sendMessageAndWait({
userId: 'user@example.com',
message: 'Quick question',
timeout: 5_000, // 5 seconds for this request
});Cancellation via AbortSignal
const controller = new AbortController();
// Set a 30-second timeout
const timeoutId = setTimeout(() => controller.abort(), 30_000);
try {
const response = await client.sendMessageAndWait({
userId: 'user@example.com',
message: 'Generate a report',
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}Cancelling a stream
const controller = new AbortController();
const stream = await client.sendMessage({
userId: 'user@example.com',
message: 'Stream results',
signal: controller.signal,
});
// Cancel after receiving first 10 events
let eventCount = 0;
for await (const event of stream) {
console.log(event);
if (++eventCount >= 10) {
controller.abort(); // Stops the iteration
}
}Idempotency
For operations that must not be retried (e.g., order placement), provide a client-generated message ID:
const response = await client.sendMessageAndWait({
userId: 'user@example.com',
message: 'Place order ABC123',
clientMessageId: 'order-abc123-1692360000', // Unique per request
});If the request fails and is retried, the same clientMessageId ensures the agent does not process the order twice.
Common use cases
Search feature
app.post('/search', async (req) => {
const { query, userId } = req.body;
const response = await client.sendMessageAndWait({
userId,
message: `Search for: ${query}`,
metadata: { source: 'search-page' },
timeout: 10_000, // Fast timeout for search
});
return response.json({ results: response.message });
});Recommendation engine
async function generateRecommendations(userId: string, context: any) {
const prompt = `Based on this context, generate 5 recommendations: ${JSON.stringify(context)}`;
const response = await client.sendMessageAndWait({
userId,
message: prompt,
metadata: { source: 'recommendation-engine' },
});
// Parse agent's response (can be structured or free-form)
return response.message;
}Autonomous workflow / job
async function processRefund(orderId: string) {
const response = await client.sendMessageAndWait({
// Server-side operation, no specific user
userId: 'system@internal',
message: `Process refund for order ${orderId}. Check status, contact customer if needed.`,
metadata: { source: 'async-job', operation: 'refund' },
timeout: 60_000, // Longer timeout for complex job
});
console.log('Refund processing result:', response.message);
return response;
}Conversation resumption
async function continueCustomerChat(userId: string, threadId: string) {
const response = await client.sendMessageAndWait({
userId,
threadId, // Resume previous conversation
message: 'What else can I help with?',
});
return response;
}Billing & attribution
Requests via the backend client are billed through the normal Chipp metering path, attributed to the organization that owns the bound agent’s application. Your organization receives the invoice; no separate billing occurs.
For cost control, consider:
- Setting reasonable timeouts to avoid long-running requests
- Monitoring token usage via the Chipp dashboard
- Implementing rate limiting on backend endpoints that call the agent
Troubleshooting
”AUTH_FAILED: Invalid client key”
- Verify
projectClientKeyis set correctly:alch_proj_... - Check that the key’s scope is
"full"(if restricted, it cannot access embed endpoints) - Rotate the key: Project Settings → API Keys → Rotate Client Key
”SESSION_EXPIRED: Token TTL exceeded”
- Verify the bound agent is online (check Chipp workspace)
- For long-running operations, use a shorter streaming window or break into multiple requests
- Anonymous sessions expire after ~1 hour; authenticated sessions last ~8 hours
”SERVICE_UNAVAILABLE”
- Check if the bound Chipp agent is online
- Verify the agent’s backend is responding (check Chipp infrastructure status)
- The client will automatically retry; monitor error frequency
”VALIDATION_ERROR: Message required”
- Ensure the
messagefield is non-empty - Check that one of
userIdoranonymousSessionIdis provided
”Timeout on streaming requests”
- Increase the timeout for the request
- If streaming, process events incrementally (don’t wait for the entire stream to buffer)
- Consider breaking large requests into multiple smaller requests
Next steps
- Inline panel — Embed UI for end users
- Floating widget — Persistent chat widget
- Installation guide — Bind an agent to your project