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.

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:

FileWhat it does
src/services/embedded-agent/client.tsmintEmbedSession(): mints a short-lived chat token on the server. The only code that reads CHIPP_PROJECT_CLIENT_KEY.
src/services/embedded-agent/backend-example.tsaskEmbeddedAgent(): a working example of a server-to-server call. Adapt or delete it.
web/src/lib/embedded-agent/embed-client/transport.tscreateHttpEmbedTransport(): the chat transport (threads, messages, streaming). The browser panel and widget use the same one.

Environment variables

VariableDefaultPurpose
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_URLhttps://build.chipp.ai/alchemist-apiWhere chat tokens are minted.
CHIPP_API_BASE_URLhttps://build.chipp.aiThe Chipp chat runtime the token is used against.

Quick start

Non-streaming (wait for the complete answer)

The generated example does this for you:

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

Streaming (receive response text in real time)

Mint a session, create a thread, and iterate the stream:

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

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

MethodPurpose
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

typescript
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

typescript
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

typescript
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

typescript
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 EmbedTransportError with status (HTTP status) and detail (the raw response body, usually { "error": { "code", "message" } }).
  • A network failure surfaces as a TypeError from fetch.
  • A problem during a reply arrives as an error event on the stream.

Error codes

CodeWhereMeaning
rate_limitedHTTP 429More than 30 requests per minute for this user
thread_not_foundHTTP 404The thread does not exist or belongs to another user
billing_blockederror stream eventThe 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:

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

typescript
const clientMessageId = 'order-abc123-1692360000';  // Reuse on retry

This 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

typescript
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

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

typescript
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_KEY is 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_URL if 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