Alchemist Operations

Alchemist: Embedded Agent — Backend Client

Call a bound Chipp agent from your application server for search, recommendations, or autonomous workflows.

|View as Markdown
Chipp EngineeringEngineering at Chipp
|1 min read
#alchemist#embedded-agent#backend#server#api

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:

bash
npm install @chipp/embed-client
# or
deno add npm:@chipp/embed-client

Quick start

1. Initialize the client

In your backend (routes, jobs, or services):

typescript
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):

typescript
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 later

Streaming (receive response tokens in real-time):

typescript
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

typescript
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

typescript
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

typescript
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:

typescript
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:

typescript
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:

typescript
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):

typescript
// 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:

typescript
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:

typescript
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:

typescript
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

CodeMeaningRetryable
AUTH_FAILEDInvalid client key or sessionNo
SESSION_EXPIREDSession TTL exceededNo
UNAUTHORIZEDUser not authorized for this bindingNo
TIMEOUTRequest exceeded timeoutYes
NETWORK_ERRORConnection failedYes
SERVICE_UNAVAILABLEAgent or backend is downYes
RATE_LIMITEDToo many requestsYes
VALIDATION_ERRORInvalid input (empty message, etc.)No

Cancellation & timeouts

Custom timeout per request

typescript
const response = await client.sendMessageAndWait({
  userId: 'user@example.com',
  message: 'Quick question',
  timeout: 5_000,  // 5 seconds for this request
});

Cancellation via AbortSignal

typescript
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

typescript
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:

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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 projectClientKey is 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 message field is non-empty
  • Check that one of userId or anonymousSessionId is 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