Server and shared components
The hooks work in any component — idle in Server Components, live in client ones.
The hooks work in any component. Call useInlayChat (or a generated
useKitchenAgent) in a Server Component, a shared component, or a client
component — no provider ceremony, no preload function, no build error. In a
Server Component it renders the idle snapshot; in a client component it
is the live chat.
Why idle
A Server Component renders once, on the server, and never hydrates — so the
only honest snapshot is the empty one: status: "idle", zero rows,
canSend: false. The read-model fields (status, rows, bindings) match
what the client build's SSR pass publishes, so server HTML and client
hydration agree on the transcript. canSend deliberately differs: the RSC
idle is inert (false — there is no live chat server-side), while the
client's SSR pass paints canSend: true for an idle session so the composer
doesn't flash disabled on hydration.
Liveness lives in client components — the same rule as onClick. A chat
becomes interactive exactly where a client component uses the hook; the
Server Component's render stays the placeholder. This is React's boundary,
not a limitation of the SDK: your own components still need "use client"
when they hold state or event handlers.
The colocated pattern
The session mint and the chat declaration can share one function — the hook takes care of which side of the boundary each half runs on:
// app/chat/page.tsx — a Server Component, no "use client" anywhere.
export default async function Page() {
// 1. The server step: mint a session from the secret key.
const session = await mintSession({
secretKey: process.env.INLAY_SECRET_KEY!,
userId: await getUserId(),
});
// 2. The SAME hook, called right here in the Server Component. In this
// graph it renders the idle snapshot — status "idle", 0 rows, canSend
// false — and calls no React hooks, so this is safe even in an async
// component.
const preview = useInlayChat({ agentId: process.env.INLAY_AGENT_ID!, session });
return (
<>
<p>The chat boots as: {preview.snapshot.status}</p>
{/* 3. The client component below calls the same hook — and there it's
the live one. The session crosses the boundary as plain data. */}
<Chat session={session} />
</>
);
}// app/chat/chat.tsx — "use client": the same call, the live hook.
"use client";
export function Chat({ session }: { session: Session }) {
const { snapshot, send, canSend } = useInlayChat({
agentId: process.env.NEXT_PUBLIC_INLAY_AGENT_ID!,
session,
});
return (
<>
{snapshot.rows.map((row, i) => (
<p key={row.id}>{row.type === "assistant" ? row.content : null}</p>
))}
<button disabled={!canSend} onClick={() => void send("hello").catch(console.error)}>
Send
</button>
</>
);
}One nuance on where the hook reads from: in a Server Component the options are accepted but never take effect — there is no transport and no wasm boot server-side. The call is a placeholder for the client one.
Reads are quiet, actions are loud
Reads in a Server Component are silent idle — every bound hook
(useBinding, useTable, useNode, useRun, …) returns its empty value,
reference-stable, so renders are cheap and never spam your server log.
Explicit actions are the opposite: send, cancel, retry, resume,
submitToolResult, loadEarlier reject with a clear error, and reset
throws one. A resolved promise would lie about work that never happened.
The generated client
Everything above holds for a codegen'd agent: useKitchenAgent is callable
anywhere (it reads idle in a Server Component, live on the client). The
agent-agnostic <InlayProvider> is a client reference — render it from a
Server Component and the real provider mounts on the client and publishes the
connection (a ghost in the RSC payload, live during SSR + hydration):
// app/layout.tsx — a Server Component.
import { InlayProvider } from "@inlayai/react";
export default async function RootLayout({ children }) {
const session = await mintSession({ secretKey: process.env.INLAY_SECRET_KEY!, userId: await getUserId() });
// One agent-agnostic provider holds the connection for EVERY agent.
return <InlayProvider session={session}>{children}</InlayProvider>;
}Only serializable props (session, baseUrl) cross the RSC boundary — and
tools live on the hook, not the provider, so the "functions can't cross"
problem never touches the provider. Tools are defined in a client module and
passed to the hook:
// app/kitchen/chat.tsx — a CLIENT module.
"use client";
import { useKitchenAgent, type KitchenToolHandlers } from "@/lib/kitchen.generated";
const TOOLS: KitchenToolHandlers = {
ask_question: () => "",
// …every declared tool
};
export function Chat() {
// Tools on the hook (typed, required); the connection reads the provider.
const agent = useKitchenAgent({ tools: TOOLS });
// …
}A standalone hook with an explicit connection (useKitchenAgent({ session, tools }))
works without any provider at all.
How it works
The package ships two builds of the same import, switched by the bundler:
the react-server export condition (RFC 0227) resolves an inert
implementation in Server Component graphs and the real one in client graphs —
the same mechanism next-intl uses for useTranslations. The generated
module delegates all of its React machinery to those builds, which is why it
needs neither "use client" nor an environment check of its own.
Next
Failure handling — run failures, retries, and the HTTP error taxonomy.