The Ambient API: connecting the layer to a backend
The ambient layer never makes a network request. The host app gives it one
object, built with createAmbientApi from
packages/ambient/src/responder.ts, and the layer calls it:
<AssistantProvider api={ambientApi}>…</AssistantProvider>
Everything behind that object belongs to the host: its HTTP client, its server, its auth, its model, and whether it is answering from stubs or from a real backend. The layer's job is the boundary. Every request and response is parsed against zod schemas typed against the kits' own interfaces, so a reply that doesn't match fails the turn with a reason instead of breaking the page.
This document covers what the contract is, which tools fit behind it, and what it doesn't do yet.
Names
Everything in the contract carries the Ambient prefix, so it reads as one
vocabulary and stands apart from the host's own types and from other
libraries (the Vercel AI SDK's UIMessage and TextStreamPart, for
example):
| Name | What it is |
|---|---|
AmbientQuestion | What ask receives: the question, the conversation, the page context. |
AmbientAnswer | A complete answer: prose, evidence, artifacts, references, follow-ups. |
AmbientAnswerEvent | One piece of a streamed answer. |
AmbientBlock, AmbientReference | The parts an answer is made of. |
AmbientTurn | One earlier turn in history. |
AmbientRecent | One row of the working history. |
AmbientApi, createAmbientApi, AmbientApiError | The API the layer calls, how to build it, and how it fails. |
The zod schemas live in packages/ambient/src/responder-schemas.ts
(ambientAnswerSchema, ambientQuestionSchema,
ambientAnswerEventSchema, …). The API around them lives in
packages/ambient/src/responder.ts. In the host, keep the prefix too:
lib/ambient/api.ts, ambientApi, VITE_AMBIENT_STUBS, and endpoints
under /ambient/, so anyone reading the codebase can tell which code
serves the assistant.
The contract
createAmbientApi takes up to three async handlers. Only ask is
required.
| Handler | Receives | Returns |
|---|---|---|
ask(input, { signal }) | { question, conversationId, history, pageChip, chips } | an AmbientAnswer (REST), or an async iterable of AmbientAnswerEvents (a stream) |
suggestions(input, { signal }) | { pageChip } | string[] |
recents({ signal }) | nothing | { text, when }[] |
- Handlers may return anything.
createAmbientApivalidates it and rejects with anAmbientApiErrornaming the field that failed. historyis every earlier turn of the conversation, oldest first, as{ role: "user" | "assistant", text }. An answer is sent as its prose, and a turn that failed before saying anything is left out. A stateless backend can pass it straight to the model.conversationIdstays the same until the person clears the conversation. A backend that keeps its own threads can key on it and ignorehistory.signalis aborted when the person presses Stop, a newer question replaces this one, or the layer unmounts. Pass it to the HTTP client so the request is actually cancelled.pageChipis what the page declared withsetPageChip. Route on itsidto answer per page.- The exported schemas (
ambientAnswerSchema,ambientQuestionSchema,ambientSuggestionsSchema,ambientRecentsSchema) are the same ones the layer checks against. Reuse them on the server where the server is TypeScript.
Whole answers and streamed answers
ask can answer in either of two ways, and the layer handles both.
REST: a whole answer. Resolve to an AmbientAnswer. The layer paces the
prose onto the screen itself.
A stream: an answer in pieces. Return an async iterable of AmbientAnswerEvents.
The layer shows each piece as it arrives: blocks appear, the prose follows
the text as it comes in, and nothing settles until the stream ends.
| Event | Effect |
|---|---|
{ type: "evidence", block } | Adds a block above the prose (reasoning, tool call, search…). |
{ type: "text", delta } | Appends to the prose. |
{ type: "artifact", block } | Adds a block below the prose (diff, terminal, report…). |
{ type: "refs", refs } | Adds references. |
{ type: "followUps", followUps } | Sets the offered next questions. |
{ type: "effect", effect } | Names the workspace effect announced on settle. |
{ type: "answer", answer } | Replaces everything so far with a complete answer. |
{ type: "error", message } | Fails the turn. What already arrived stays, with the reason under it. |
Send evidence before the text that rests on it; the layer renders in arrival order. Every event is validated, so one malformed event fails the turn with the field that was wrong.
Transports
The layer only ever sees the iterable, so the transport is the host's choice:
- Server-sent events (supported now).
answerEventsFromSSE(response)turns atext/event-streamresponse into events. Each event'sdata:line is oneAmbientAnswerEventas JSON, anddata: [DONE]or the end of the body ends the answer. - WebSocket, WebRTC data channel, gRPC (not built yet). Each needs only
an adapter that turns its messages into an async iterable of
AmbientAnswerEvents, the same jobanswerEventsFromSSEdoes for SSE. The layer and the contract don't change. We'll add adapters when a host needs one.
A host can expose both a REST and a streaming endpoint and choose per environment or per request. Stubs usually answer whole; a production server usually streams.
Who this is for
The layer is the presentation. It works with any tool that produces data and leaves the UI to us. A tool that brings its own chat UI, or opinions about how answers look, overlaps with the layer and is out of scope.
| Tool | Fits? | How |
|---|---|---|
fetch, axios, ky, generated clients (openapi-ts, orval), tRPC | Yes | A handler is any async function. All of them accept an AbortSignal, so Stop cancels the request. |
| TanStack Query, SWR | Yes | Call the query client inside a handler (queryClient.fetchQuery). Caching stays in the host. |
Vercel AI SDK on the server (generateObject, generateText) | Yes, closely | generateObject({ schema: ambientAnswerSchema }) makes the model produce the layer's grammar directly, and the browser validates it again with the same schema. |
| OpenAI and Anthropic SDKs, LangChain / LangGraph, Mastra, on the server | Yes | The server runs the harness and returns an AmbientAnswer as JSON. Model keys never reach the browser. |
| Any backend language (FastAPI, Rails, Go…) | Yes | Return the same JSON shape. Mirror the schema by hand for now (see the gaps below). |
Vercel AI SDK UI (useChat) | Not needed | It manages chat state and messages in the browser, which the layer already does. Use the AI SDK on the server instead. |
| assistant-ui, CopilotKit, other chat UI kits | Out of scope | They have opinions about the presentation layer. A host could still run their backend runtimes behind its own server. |
Examples
How it relates to the Vercel AI SDK
It works well alongside it, but it isn't the AI SDK's own protocol:
- Server side, it fits directly.
generateObject({ schema: ambientAnswerSchema })produces anAmbientAnswer, andstreamText'stextStreammaps one-to-one ontotextevents (examples below). - The wire format is ours. The SSE events are
AmbientAnswerEvents, not the AI SDK's UI message stream, sotoUIMessageStreamResponse()can't be consumed as-is. The server maps stream parts to events, which is a few lines. An adapter from AI SDK stream parts is listed in the gaps below. useChatisn't needed. The layer already owns chat state.
With the project's existing HTTP client
// lib/ambient/api.ts
import { createAmbientApi } from "@/components/ambient/responder"
import { http } from "@/lib/http" // the project's own axios instance
import { stubs } from "./stubs"
const USE_STUBS = import.meta.env.VITE_AMBIENT_STUBS !== "false"
export const ambientApi = createAmbientApi(
USE_STUBS
? stubs
: {
ask: (input, { signal }) =>
http.post("/ambient/ask", input, { signal }).then((r) => r.data),
suggestions: (input, { signal }) =>
http.post("/ambient/suggestions", input, { signal }).then((r) => r.data),
}
)
A streaming endpoint (SSE)
// lib/ambient/api.ts — streaming variant
import { createAmbientApi, answerEventsFromSSE } from "@/components/ambient/responder"
export const ambientApi = createAmbientApi({
ask: async (input, { signal }) =>
answerEventsFromSSE(
await fetch(`${import.meta.env.VITE_API_URL}/assistant/ask/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
signal,
})
),
})
A server route with the Vercel AI SDK, whole answer
// app/api/ambient/ask/route.ts (Next.js)
import { anthropic } from "@ai-sdk/anthropic"
import { generateObject } from "ai"
import { ambientQuestionSchema, ambientAnswerSchema } from "ambientui/responder"
export async function POST(req: Request) {
const input = ambientQuestionSchema.parse(await req.json())
const { object } = await generateObject({
model: anthropic("claude-sonnet-5"),
schema: ambientAnswerSchema,
system: `You answer questions about this product. The person is on: ${input.pageChip?.label ?? "an unknown page"}. Cite the records you used in refs.`,
messages: [
...input.history.map((turn) => ({ role: turn.role, content: turn.text })),
{ role: "user", content: input.question },
],
abortSignal: req.signal,
})
return Response.json(object)
}
A server route with the Vercel AI SDK, streamed over SSE
// app/api/ambient/ask/stream/route.ts (Next.js)
import { anthropic } from "@ai-sdk/anthropic"
import { streamText } from "ai"
import { ambientQuestionSchema, type AmbientAnswerEvent } from "ambientui/responder"
export async function POST(req: Request) {
const input = ambientQuestionSchema.parse(await req.json())
const result = streamText({
model: anthropic("claude-sonnet-5"),
system: `You answer questions about this product. The person is on: ${input.pageChip?.label ?? "an unknown page"}.`,
messages: [
...input.history.map((turn) => ({ role: turn.role, content: turn.text })),
{ role: "user", content: input.question },
],
abortSignal: req.signal,
})
const encoder = new TextEncoder()
const send = (event: AmbientAnswerEvent | "[DONE]") =>
encoder.encode(`data: ${typeof event === "string" ? event : JSON.stringify(event)}\n\n`)
const body = new ReadableStream({
async start(controller) {
for await (const delta of result.textStream)
controller.enqueue(send({ type: "text", delta }))
controller.enqueue(send("[DONE]"))
controller.close()
},
})
return new Response(body, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
})
}
Tool calls in the stream (result.fullStream) map to evidence events the
same way: a tool call and its result become a tool block.
A non-TypeScript backend
A FastAPI endpoint returns the same JSON. Until the schema is exported as JSON
Schema (see the gaps below), mirror AmbientAnswer in pydantic by hand from the
schemas in responder.ts. The browser still validates every reply, so drift
shows up as a clear error, not a broken page.
Gaps, and what we plan to do
| Gap | Effect today | Plan |
|---|---|---|
| No WebSocket, WebRTC or gRPC adapters. Only SSE ships a helper. | A host on another transport writes the small adapter itself (messages → async iterable of AmbientAnswerEvents). | Add adapters when a host needs one. The contract doesn't change. |
Evidence blocks keep their staged timing. Each block type has a built-in reveal (a tool call holds about 2 s, a reasoning block 4 s unless it declares seconds), designed for demos that had no real work behind them. | With a real backend, and especially a stream, that timing adds seconds after the work is already done. | Open design decision: let a host choose staged or immediate evidence, likely as a runtime setting. |
| Tool calls need translating. Model tool calls and results are not answer blocks by themselves. | The host maps them to tool, parallel, reasoning or search blocks, or events. | generateObject with ambientAnswerSchema does it for whole answers. A helper for AI SDK stream parts could follow. |
No human-in-the-loop step. Answers are one-way; effect only announces what happened. | A backend can't pause for the person to approve an action. | A later contract addition if hosts need it. |
| No JSON Schema export. The schemas exist only as zod. | Non-JS servers mirror the shape by hand. | Export z.toJSONSchema(ambientAnswerSchema) so any backend can generate its models from it. |
Resolved: conversation history (history and conversationId on every
ask) and streaming (async iterable of AmbientAnswerEvents, with SSE supported).