#AI assistant
alepha/ai puts a model behind one provider contract and gives your app an assistant that
knows the page the user is on and can run your features for them. There is no vendor SDK:
every provider is plain fetch and ReadableStream, so the same code runs on Node, Bun and
Cloudflare Workers.
1import { $ai, AiProvider } from "alepha/ai";
#Choosing a provider
The vendor is the module you register, never an env string. Without one, MemoryAiProvider
answers: specs script it, and in development it replies that no vendor module is registered,
so a page with a chat renders and names its own cause.
| module | provider | for |
|---|---|---|
AlephaAiCloudflare (alepha/ai/cloudflare) |
WorkersAiProvider |
Workers AI through the env.AI binding, on Workers |
AlephaAiOpenai (alepha/ai/openai) |
OpenAiCompatibleAiProvider |
OpenAI, Workers AI over REST, OpenRouter, Ollama, vLLM |
AlephaAiAnthropic (alepha/ai/anthropic) |
AnthropicAiProvider |
the Anthropic Messages API, with prompt caching |
Start with Workers AI, and fall back to Anthropic where it cannot do the job (a model without function calling, or answers that are not good enough). The choice is per app; there is no automatic failover between vendors.
AlephaAiCloudflare only substitutes the provider on Workers, so an app deployed there can
register it unconditionally and still run yarn dev on Node. Register AlephaAiOpenai after
it to reach the same Workers AI models over REST locally and in Docker:
1import { Alepha, run } from "alepha";2import { AlephaAiCloudflare } from "alepha/ai/cloudflare";3import { AlephaAiOpenai } from "alepha/ai/openai";4 5run(Alepha.create().with(AlephaAiCloudflare).with(AlephaAiOpenai));
The first substitution wins, so the order matters: on Workers the binding provider substitutes
first and answers; elsewhere it substitutes nothing and the REST provider answers. The Cloudflare build declares the ai binding when the module is registered.
| variable | read by | meaning |
|---|---|---|
AI_MODEL |
every vendor provider | the model id; there is no default, and a vendor provider fails at start without one (unless every $ai names a model) |
AI_API_KEY |
Anthropic, OpenAI-compatible | the vendor key (a Cloudflare API token for Workers AI over REST) |
AI_BASE_URL |
Anthropic, OpenAI-compatible | the endpoint: Cloudflare AI Gateway, https://api.cloudflare.com/client/v4/accounts/<id>/ai/v1, http://localhost:11434/v1 |
AI_BINDING |
Workers AI | the binding name, AI by default |
Every failure is a typed error from one family, whatever the vendor: AiAuthError,
AiRateLimitError (with retryAfter), AiOverloadedError, AiContextLengthError,
AiToolsUnsupportedError (the model cannot call tools), all extending AiError.
#Calling a model directly
AiProvider streams one response as typed events. complete() collects them:
1import { $inject } from "alepha"; 2import { AiProvider } from "alepha/ai"; 3 4class Summary { 5 ai = $inject(AiProvider); 6 7 async summarize(text: string) { 8 const reply = await this.ai.complete({ 9 system: "Summarize in one sentence.",10 messages: [{ role: "user", content: text }],11 });12 return reply.text;13 }14}
#An assistant: $ai
$ai declares an assistant whose tools are your $actions:
1import { $inject, z } from "alepha"; 2import { $ai, type AiMessage } from "alepha/ai"; 3import { $secure, type UserAccountToken } from "alepha/security"; 4import { $action } from "alepha/server"; 5 6class InvoiceApi { 7 list = $action({ 8 method: "GET", 9 path: "/invoices",10 description: "List the invoices of the signed-in user",11 use: [$secure()],12 schema: {13 query: z.object({ status: z.enum(["open", "paid"]).optional() }),14 response: z.array(z.object({ id: z.integer(), status: z.text() })),15 },16 handler: () => [],17 });18 19 create = $action({20 method: "POST",21 path: "/invoices",22 description: "Create an invoice",23 use: [$secure()],24 schema: {25 body: z.object({ amount: z.number() }),26 response: z.object({ id: z.integer() }),27 },28 handler: () => ({ id: 1 }),29 });30}31 32class Support {33 invoices = $inject(InvoiceApi);34 35 assistant = $ai({36 system: "You help with invoices. Be brief.",37 tools: [this.invoices.list, this.invoices.create],38 });39 40 async answer(messages: AiMessage[], user: UserAccountToken) {41 const result = await this.assistant.run({ messages, user });42 return result.text;43 }44}
- A tool is an action. Its name is the action's name (letters, digits,
_and-, at most 64), its description the action'sdescription(required: it is how the model knows when to call it), and its input an object withparams,queryandbodyfor the parts the action declares. A bad name or a missing description fails at start. - It runs as the user. Each call goes through
action.run(input, { user })with the user you pass, so the action's ownuse: [$secure(...)]decides: the assistant can never do more than the user could. Without a user the call runs anonymous and a secured action refuses; there is no "system" fallback. A refusal, a validation error or a thrown error goes back to the model as a tool error, never to the browser as a 403. - Bounds.
maxSteps(10 model calls by default) ends the turn withend: length;timeoutand the ambient abort signal cancel the call in flight.
#Confirmation and resume
A call to an action whose method is not GET (or whatever confirm says) does not run on its
own. The turn pauses: stream() emits a confirm event with the call id and its input, then
end: paused. A client tool, declared with clientTool(name, { description, schema }), pauses
the same way with a client_tool event, because the browser runs it (navigate, fillForm).
The loop is re-entrant: its whole state is the message list. Persist every message event as
it comes; resuming is a new call with the stored messages and the user's answer:
1await assistant.run({ 2 messages: stored, 3 user, 4 resume: { callId, decision: "approve" }, 5}); 6await assistant.run({ 7 messages: stored, 8 user, 9 resume: { callId, result: { ok: true } },10});
An approved call runs the input stored in the messages, never input sent back by the browser, and a call id nothing waits on is refused. A denial reaches the model as the tool error "Denied by the user.". Nothing lives in memory between the two requests, which is the shape that works on Workers.
#Page context
Pass context: { route, page, data } with a new turn and it rides on the user's message as a
delimited <page-context> block, capped at 8 KB and truncated with a marker past it. It never
goes into the system prompt: it changes every turn, which would void the vendor's prompt cache,
and it is client-sent data, which the model must read as the user's. The augmented message is
emitted as a message event, so what you store is what the model saw; AiContextBlock.strip()
removes the block for display.
#The server side: alepha/api/ai
AlephaApiAi stores conversations per user and serves them, on top of your $ai:
| endpoint | kind | does |
|---|---|---|
POST /api/ai/chat |
$sse |
a new turn, in a new conversation or conversationId's |
POST /api/ai/conversations/:id/resume |
$sse |
the answer to a pause: { callId, decision } or { callId, result } |
GET /api/ai/conversations |
$action |
the caller's conversations, most recent first |
GET /api/ai/conversations/:id |
$action |
one conversation with its messages |
Every endpoint is $secure() and scoped to the caller: another user's conversation is a 404,
never a 403. On the streams, every refusal happens before the stream opens, so it is the
response's status: 401 signed out, 404 not yours, 409 not waiting on that call, 429 over the turn
rate or the token budget (AiBudgetExceededError). Each message is stored as it completes,
tokens on the assistant rows; a pause stores the call, and a resume claims it once.
1import { Alepha, run } from "alepha";2import { AlephaAiCloudflare } from "alepha/ai/cloudflare";3import { AlephaApiAi } from "alepha/api/ai";4 5// The vendor module FIRST: AlephaApiAi registers alepha/ai, and the first6// provider substituted is the one used.7run(Alepha.create().with(AlephaAiCloudflare).with(AlephaApiAi).with(MyApp));
The module ships its entities (ai_conversations, ai_messages), not migrations: generate them
with the app's (alepha db migrations create), as for every alepha/api/* module. With several
$ais in the app, a chat names the one it talks to (assistant).
#Budget, rate and retention: the api.ai parameter
Runtime settings, editable by an operator without a redeploy (alepha/api/parameters):
| key | default | meaning |
|---|---|---|
budgetTokens |
unset | tokens (input plus output) one user may spend per window; unset is no budget |
budgetWindowHours |
24 | the budget window, sliding, in hours |
turnsPerMinute |
20 | turns one user may start per minute ($rateLimit) |
retentionDays |
unset | days a conversation is kept after its last activity; unset is keep |
The budget is checked before a turn from the stored token counts, so a turn can overshoot by its
own size. Retention is a daily $job that reads the parameter at every tick. Page data and tool
results live in the messages, so retention is a privacy setting as much as a storage one.
#The client side: @alepha/ui/ai
1import { AiChat, useAiContext } from "@alepha/ui/ai"; 2 3const InvoicePage = (props: InvoicePageProps) => { 4 const form = useForm({ schema: invoiceSchema, handler: save }); 5 // What the page shows, and the form the assistant may fill (never submit). 6 useAiContext({ page: "invoice", data: props.invoice, form }); 7 const [open, setOpen] = useState(false); 8 return ( 9 <>10 <Button onClick={() => setOpen(true)}>Ask</Button>11 <AiChat open={open} onOpenChange={setOpen} invalidates={[["invoices"]]} />12 </>13 );14};
<AiChat />is a drawer: streamed markdown replies, the conversation history, and a confirmation card for every mutation, which shows the input the server stored and answers with the call id only.useAiChat()is the same state and actions without the UI:items,streaming,pending,send,approve,deny,open, each resolving aRunResult.invalidateslists the query keys to refresh after a turn ran a tool, so the page shows what the assistant changed.useAiContext({ page, data, form })declares what a page shows. Every turn carries the current route and the innermost declared context.- Client tools run in the browser and the turn resumes with their result on its own:
navigate(by page name, through the typed router) andfillForm(only the form a page offered, never submitted). Declare them on your$aiwithaiNavigateToolandaiFillFormToolfromalepha/ai; your own go inuseAiChat({ clientTools }).
The browser opens a $sse through useClient() like any action: a refusal rejects with its typed
error, the events arrive as they are sent.
#One build, Node and Workers
apps/examples/ai is a notes app with one read action, one write action and an assistant, built
once with alepha build --runtime node,workerd:
- on Workers,
AlephaAiCloudflareanswers through theenv.AIbinding, which the build declares inwrangler.jsonc(and the Lore deploy uploads); - in Docker (
alepha image), the same build runs on Node, andAlephaAiOpenaireaches the same Workers AI models over REST withAI_BASE_URL=https://api.cloudflare.com/client/v4/accounts/<id>/ai/v1and a Cloudflare API token asAI_API_KEY.
Only the environment differs. Its e2e runs the Node build against a scripted MemoryAiProvider:
ask, confirm the write, see the page change.
#Testing
MemoryAiProvider is the default under test. Script one response per model call:
1import { Alepha } from "alepha";2import { AlephaAi, MemoryAiProvider } from "alepha/ai";3 4const alepha = Alepha.create().with(AlephaAi);5const ai = alepha.inject(MemoryAiProvider);6ai.script([{ type: "tool_call", id: "c1", name: "list", input: {} }]);7ai.script([{ type: "text", text: "You have two invoices." }]);
ai.requests keeps every request, so a spec asserts what the model would have been sent.