Start Here
Build a chat app
Put a streaming chat with each user's agent in your app: one server route and one component
View as Markdown · Docs index for coding agents
Your users talk to their own agents in your app. Replies stream in as the model writes them, tool calls show as cards (or as your own components), and questions and approvals are answered in place. Your API key stays on your server.
Start a new app
export CAMELAI_API_KEY=art_... # https://run.camelai.com/console/tokens
npm create @camelai/run-app my-app
cd my-app && npm run devIt writes the key from CAMELAI_API_KEY to the app's .env.local, so the key
never lands in your shell history.
This creates a Next.js app with the chat, the server route it talks to, a tool
drawn by its own component, and a demo sign-in for you to replace with your own.
Pass --base-url to point it at a self-hosted runtime.
The demo sign-in treats every browser as a user. Deployed with
NODE_ENV=production, it refuses everyone until you replace it in
app/api/agent/route.ts (or set DEMO_AUTH=1).
Add it to an app you have
npm install @camelai/run @camelai/run-reactAdd the route
One POST route does everything the browser needs. It checks who the user is with your own sign-in, gives their browser a short-lived token to read their agent, and sends their messages and answers.
import { createAgentHandler } from "@camelai/run/server";
export const POST = createAgentHandler({
// Reads CAMELAI_API_KEY from the environment, or pass apiKey.
authorize: async request => {
const user = await getUser(request); // your session check
return user ? { userId: user.id, name: user.name } : null; // null: 401
},
agent: {
instructions: "You help customers with their orders.",
},
});The handler is a standard fetch handler, so it runs anywhere:
// Hono
app.post("/api/agent", c => handler(c.req.raw));
// Cloudflare Workers, Bun, Deno
export default { fetch: handler };
// Express or node:http, mounted before any body parser for this route
import { nodeListener } from "@camelai/run/node";
app.post("/api/agent", nodeListener(handler));Add the chat
"use client";
import { AgentChat } from "@camelai/run-react/ui";
import "@camelai/run-react/styles.css";
export default function Support() {
return <AgentChat endpoint="/api/agent" suggestions={["Where is my order?"]} />;
}<AgentChat> fills its container, so give the container a height.
Route options
| Option | |
|---|---|
authorize(request, { thread, action }) | Required. Return { userId, name?, agentKey? }, or null for a 401 |
agent | How the user's agent is made: { instructions, model, tools, definition, context, spendLimit, ... }, or a function of the user |
onSend({ auth, text, data, request }) | Runs before each message. Return { text?, metadata? }, or throw new Response(...) to refuse it (quotas, moderation) |
browserToken | { ttlSeconds, events, redact, url } for the tokens it mints. Default 15 minutes, with provider costs left out |
allowedOrigins | Other origins whose pages may call the route, with CORS |
proxy | true: browsers also read their agent through this route and only ever talk to your origin. See below |
linkAnyMountedPath | true: users may download any file in the agent's mounts. By default, only files the agent presented or wrote to its own workspace |
apiKey, url | Default CAMELAI_API_KEY, and CAMELAI_BASE_URL or https://run.camelai.com |
How it stays secure
- The API key stays on your server. The browser gets a browser token that reads one agent's events, history, state and inputs for 15 minutes. It can't send, answer, stop, or read another agent. The chat renews it through your route.
- Every write goes through your route, after
authorize. The browser never names an agent: the route picks the user's agent from whatauthorizereturned, by default one per user and thread. - Messages and answers are sent as the user (
from: { id: userId, name }), never as whatever the browser claims. - Sends are idempotent. Each carries the id the browser gave the message, so a retry never sends twice.
- Cross-site requests are refused. The route only takes
application/jsonand refusesSec-Fetch-Site: cross-siteunless you list the origin. - Rendering is safe. Markdown is rendered as elements, never raw HTML.
Links are only http(s) and mailto, and images the model links are shown as
links unless you set
allowImages.
Customize the UI
Replace any part of the prebuilt chat:
<AgentChat
endpoint="/api/agent"
tools={{ get_weather: WeatherCard }} // your component per tool
components={{ Markdown: MyMarkdown, EmptyState: Welcome }}
labels={{ placeholder: "Ask about your order" }} // every string, for translation
theme="system" // or "light" / "dark"
/>Theme it with CSS variables on .agent-chat, such as --agent-accent,
--agent-radius, --agent-font and --agent-max-width. With shadcn/ui,
also import @camelai/run-react/shadcn.css to use your theme's
tokens.
Tools with their own UI
Give the agent tools in the route, and draw their calls with your own components in the browser:
import { schema, tool } from "@camelai/run";
agent: {
tools: {
get_weather: tool({
description: "The weather in a city",
input: schema.Object({ city: schema.String() }),
execute: async ({ city }, { identity }) => weather.now(city), // identity.subject is the user
}),
},
},A renderer gets args (partial while the model writes them), state
(input_streaming, running, input_required, done or error), result,
progress, and for a call that waits on the user, its input and
answer(value).
Tools given in the route run in that server process, so it must keep running
(next start, a container, a Node or Bun server). On serverless hosting,
serve the tools over HTTP with serveTools and name them in a definition:
agent: { definition: "def_..." }. See Served tools.
Threads
A thread is your name for one of the user's conversations. Each thread is its
own agent, with its own history:
<AgentChat endpoint="/api/agent" thread={conversationId} />The route receives it in authorize(request, { thread }). To let several
people share one agent (a team's assistant), check access and return the key
yourself:
authorize: async (request, { thread }) => {
const user = await getUser(request);
if (!user) return null;
if (thread && !(await isMember(user.id, thread))) return null;
return { userId: user.id, name: user.name, agentKey: thread ? `team-${thread}` : undefined };
},Everyone in a shared thread sees the same conversation live, and it runs one turn at a time. Put private work in per-user agents.
Read through your route
By default the browser reads its agent's stream straight from camelRun with its
browser token. With proxy: true, your route passes those reads through too,
so the browser only talks to your origin and the runtime can stay private:
const handler = createAgentHandler({ authorize, agent, proxy: true });
export const GET = handler;
export const POST = handler;Each open chat then holds a request on your server. Serverless platforms cut long requests at their time limit; the chat reconnects, and falls back to long polls of at most 25 seconds. Use the proxy when policy requires a single origin.
The route's protocol
The chat talks to your route in plain JSON, so anything that can POST can use it: a native app, a
test, a script or curl. Each request is POST <route> with Content-Type: application/json, the
cookie or header your authorize reads, and a body { action, thread?, … }. thread (1 to 200
characters, or null) picks the conversation; with the user, it picks the agent
(agentKeyFor(userId, thread)) unless authorize names one.
action | Body | Answer |
|---|---|---|
token | { token, expiresAt, agentId, url }: a browser token that reads the agent (/v1/agents/{id}/{events,history,state,inputs} at url). With proxy: true, { proxy: true, agentId, token: "", expiresAt }: read under the route instead. | |
send | { text, clientId, data?, whileRunning?, wait? } | { requestId, state, steeredInto? }, as soon as the message is accepted. clientId (8 to 80 letters, digits, _ and -) is the message's id: sending the same one again sends nothing twice. whileRunning is queue (the default) or steer. data reaches only your onSend. With wait, see below. |
wait | { requestId, wait? } | A message sent before, by its clientId, as send with wait answers. Sends nothing. |
answer | { inputId, answer: { action, content? } }, action being accept, decline or cancel | { input, request }, as POST /v1/agents/{id}/inputs/{inputId} answers. The answer is the user's (from). |
stop | { aborted: true }: the agent's running turn is stopped. | |
link | { path } | { url, expiresAt }: a download link for a file the agent presented or that is in its own workspace (any mounted path with linkAnyMountedPath). |
With proxy: true the route also takes GET <route>[/threads/{thread}]/v1/agents/{id}/{events,history,state,inputs}
(the runtime's read routes, for the user's own agent only) and the file links that link gives.
Errors are { error: { code, message } } with the HTTP status: 401 unauthorized (authorize
returned null), 400 invalid_request or invalid_json (the message names the field), 403
forbidden_origin (another site; see allowedOrigins), 405, 413 too_large, 415
unsupported_media_type, and the runtime's own errors passed on with their status and code
(INSUFFICIENT_CREDIT, not_found, …; 502 runtime_unreachable when it cannot be reached). A
Response that your onSend throws is answered as it is.
Waiting for the reply
The chat follows the agent's stream, so it sends without waiting. A caller without the stream
passes wait: true (25 seconds) or whole seconds from 1 to 25. The route answers once the run
ends, with state: "completed" and the run's reply (its final message's text), error (why it
failed) or stopped (input_required or spend_limit). A run still going when the wait is over
answers state: "running": ask again with wait.
curl -s https://app.example.com/api/agent -H "Cookie: session=…" -H "Content-Type: application/json" \
-d '{"action":"send","text":"Where is my order?","clientId":"msg_0001","wait":true}'
# {"requestId":"msg_0001","state":"completed","reply":"It shipped yesterday…"}
curl -s https://app.example.com/api/agent -H "Cookie: session=…" -H "Content-Type: application/json" \
-d '{"action":"wait","requestId":"msg_0001","wait":25}'Each wait holds a request on your server: keep wait under your platform's limit (serverless
functions cut requests at theirs). Without the handler, the runtime's
GET /v1/agents/{id}/requests/{requestId}?wait=25 does the same with your API key.