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.
What the pack generates
There is no separate package to install. When the embedded-Chipp-agent feature pack is installed, it writes these files into your project:
| File | What it does |
|---|---|
src/services/embedded-agent/client.ts | mintEmbedSession(): mints a short-lived chat token on the server. The only code that reads CHIPP_PROJECT_CLIENT_KEY. |
src/services/embedded-agent/backend-example.ts | askEmbeddedAgent(): a working example of a server-to-server call. Adapt or delete it. |
web/src/lib/embedded-agent/embed-client/transport.ts | createHttpEmbedTransport(): the chat transport (threads, messages, streaming). The browser panel and widget use the same one. |
Environment variables
| Variable | Default | Purpose |
|---|---|---|
CHIPP_PROJECT_CLIENT_KEY | (none) | Your project’s alch_proj_... key. The platform provisions it when the pack is installed. Never expose it to the browser. |
CHIPP_PLATFORM_API_URL | https://build.chipp.ai/alchemist-api | Where chat tokens are minted. |
CHIPP_API_BASE_URL | https://build.chipp.ai | The Chipp chat runtime the token is used against. |
Quick start
Non-streaming (wait for the complete answer)
The generated example does this for you:
import { askEmbeddedAgent } from '@/services/embedded-agent/backend-example.ts';
const answer = await askEmbeddedAgent(
'user_123', // externalUserId: your stable user id
'user@example.com', // email
'What are the trending products this week?',
);
console.log(answer); // Agent's full reply as a stringStreaming (receive response text in real time)
Mint a session, create a thread, and iterate the stream:
import { mintEmbedSession } from '@/services/embedded-agent/client.ts';
import { createHttpEmbedTransport } from '../../../web/src/lib/embedded-agent/embed-client/transport.ts';
const minted = await mintEmbedSession({
mode: 'authenticated',
externalUserId: 'user_123',
email: 'user@example.com',
});
if (!minted.ok) throw new Error(`embedded agent unavailable: ${minted.code}`);
const transport = createHttpEmbedTransport({
baseUrl: minted.session.runtimeBaseUrl,
token: minted.session.token,
applicationId: minted.session.applicationId,
});
const thread = await transport.createThread();
for await (const event of transport.sendMessage(thread.id, 'Summarize my last 10 orders', {
clientMessageId: crypto.randomUUID(),
})) {
if (event.type === 'text-delta') {
process.stdout.write(event.delta);
} else if (event.type === 'citations') {
console.log('Citations:', event.citations);
} else if (event.type === 'error') {
throw new Error(event.message);
}
}Minting a session
mintEmbedSession(identity) never throws. It returns a typed result:
type MintResult =
| { ok: true; session: { token: string; applicationId: string; expiresAt: string; runtimeBaseUrl: string } }
| { ok: false; status: 502 | 503; code: 'not_configured' | 'session_mint_failed' };not_configured(503):CHIPP_PROJECT_CLIENT_KEYis not set.session_mint_failed(502): the mint request failed or returned an unexpected body.
Session tokens last 1 hour. Mint a new one when it expires.
Transport methods
createHttpEmbedTransport({ baseUrl, token, applicationId }) returns:
| Method | Purpose |
|---|---|
createThread() | Start a new conversation. Returns { id, title, updatedAt }. |
listThreads() | List this user’s own threads. |
getHistory(threadId) | Load the stored messages of a thread. |
sendMessage(threadId, text, { clientMessageId, signal?, hostContext? }) | Send a message and stream the reply. |
cancel(threadId) | Stop an in-flight reply. Safe to call when nothing is running. |
hostContext is optional page or record context for one message (string, number, or boolean values). The runtime drops any key that is not listed in contextKeys in chipp/agent.json. Never put personal data in it.
Streaming events
type EmbedStreamEvent =
| { type: 'start'; messageId: string; threadId: string }
| { type: 'text-delta'; delta: string }
| { type: 'citations'; citations: { id: string; title: string; fileType: string }[] }
| { type: 'done'; finishReason: string }
| { type: 'ping' }
| { type: 'conversation-frozen'; tagId: string }
| { type: 'error'; message: string; code?: string }
| { type: 'tool-confirmation'; confirmationId: string; toolName: string; title: string; summary: string; expiresAt: string }
| { type: 'tool-confirmation-resolved'; confirmationId: string; decision: 'approved' | 'declined' | 'expired' };There are no separate tool-call or file events on this stream. A tool-confirmation event means the agent wants to change data in your app; nothing runs until a person confirms it.
Identity management
Identity is always explicit. You pass it to mintEmbedSession().
Authenticated users
const minted = await mintEmbedSession({
mode: 'authenticated',
externalUserId: user.id, // Stable, unique per user
email: user.email,
name: user.name, // Optional
});Important: externalUserId should be stable and unique per user. Conversations are attributed to this identity; changing it gives the user a separate history.
Anonymous/visitor sessions
const minted = await mintEmbedSession({ mode: 'anonymous' });Anonymous sessions work only when anonymous access is enabled on the project’s binding. Generating the integration in anonymous or both mode turns it on. Each anonymous mint creates a new visitor, so reuse the token for the visitor’s conversation instead of minting per message.
Upgrading an anonymous session
const upgraded = await upgradeEmbedSession(
{ externalUserId: user.id, email: user.email, name: user.name },
anonymousToken, // the visitor's current anonymous token, or undefined
);
if (upgraded.ok) {
// upgraded.session: { token, applicationId, expiresAt, runtimeBaseUrl }
// upgraded.merged: true when the anonymous conversation moved to the user
}upgradeEmbedSession is in src/services/embedded-agent/client.ts (pack 2.4.0 and later). It never throws: a missing key is { ok: false, status: 503 } and any upstream failure { ok: false, status: 502 }. Take the identity from your server session, never from a request body. In “Both” mode the generated router already exposes this as POST /api/embedded-agent/session/upgrade.
Errors
- A failed transport call throws
EmbedTransportErrorwithstatus(HTTP status) anddetail(the raw response body, usually{ "error": { "code", "message" } }). - A network failure surfaces as a
TypeErrorfromfetch. - A problem during a reply arrives as an
errorevent on the stream.
Error codes
| Code | Where | Meaning |
|---|---|---|
rate_limited | HTTP 429 | More than 30 requests per minute for this user |
thread_not_found | HTTP 404 | The thread does not exist or belongs to another user |
billing_blocked | error stream event | The organization that owns the agent is out of credits or blocked by a billing gate |
An expired or revoked token is rejected with HTTP 401 (invalid_token). Mint a new session and retry.
Cancellation
Pass an AbortSignal to stop a request:
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30_000);
try {
for await (const event of transport.sendMessage(thread.id, 'Generate a report', {
clientMessageId: crypto.randomUUID(),
signal: controller.signal,
})) {
// ...
}
} finally {
clearTimeout(timeoutId);
}To stop a reply on the server too, call transport.cancel(thread.id).
Idempotency
Every send takes a clientMessageId. If you retry the same send, reuse the same value: the runtime collapses a duplicate it receives within 10 seconds into the original turn instead of creating a second one.
const clientMessageId = 'order-abc123-1692360000'; // Reuse on retryThis is a short dedupe window for retries, not a permanent guarantee. For operations that must never run twice (for example, placing an order), enforce idempotency in your own tool or data layer.
Common use cases
Search feature
app.post('/search', async (c) => {
const { query } = await c.req.json();
const user = getUser(c);
const answer = await askEmbeddedAgent(user.id, user.email, `Search for: ${query}`);
return c.json({ results: answer });
});Recommendation engine
async function generateRecommendations(user: { id: string; email: string }, context: unknown) {
const prompt = `Based on this context, generate 5 recommendations: ${JSON.stringify(context)}`;
return await askEmbeddedAgent(user.id, user.email, prompt);
}Conversation resumption
Keep the thread id and the session, then send to the same thread:
for await (const event of transport.sendMessage(threadId, 'What else can I help with?', {
clientMessageId: crypto.randomUUID(),
})) {
// ...
}A thread belongs to the identity that created it, so resume it with a session minted for the same user.
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
not_configured
CHIPP_PROJECT_CLIENT_KEYis not set in the project’s environment. The platform provisions it when the pack is installed.
session_mint_failed
- Verify the key is an
alch_proj_...key for this project with full scope. - Verify the project has an active agent binding.
- Check
CHIPP_PLATFORM_API_URLif you overrode it.
HTTP 401 from the chat runtime
- The session token expired (1 hour) or was revoked. Mint a new session.
rate_limited
- Slow down requests for this user, or spread work across time.
Long replies
- Process events as they arrive instead of waiting for the full stream.
- 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