Getting Started
Quick Start
Create an agent, stream it through a server procedure, and render it with the chat components.
This page shows the smallest end-to-end shape that matches how the package is built.
1. Create an agent
import "server-cli-only";
import { createAgent, loadAgentInstructions } from "@tulip-systems/ai/agents/server";
import { isStepCount } from "ai";
import { openrouter } from "./providers";
export const assistantAgent = createAgent({
model: openrouter("openai/gpt-5-mini"),
instructions: loadAgentInstructions(import.meta.url),
stopWhen: isStepCount(10),
tools: {},
});Place an instructions.md file next to the agent when using loadAgentInstructions(import.meta.url).
2. Stream the agent from your server boundary
import { type } from "@orpc/server";
import { streamAgentChatEventIterator } from "@tulip-systems/ai/chat/server";
import type { UIMessage } from "ai";
import { assistantAgent } from "@/server/agents/assistant/agent";
import { publicProcedure } from "@/server/router/procedures";
export const assistantRouter = {
chat: publicProcedure
.input(type<{ chatId: string; messages: UIMessage[] }>())
.handler(async ({ input, signal }) =>
streamAgentChatEventIterator({
agent: assistantAgent,
messages: input.messages,
abortSignal: signal,
}),
),
};3. Render a chat client
"use client";
import { useChat } from "@ai-sdk/react";
import { eventIteratorToUnproxiedDataStream } from "@orpc/client";
import {
ChatComposer,
ChatComposerInput,
ChatComposerSubmit,
ChatComposerToolbar,
ChatThread,
ChatThreadActivity,
ChatThreadContent,
ChatThreadMessage,
} from "@tulip-systems/ai/chat/client";
import { useState } from "react";
import { orpc } from "@/server/router/client";
export function AssistantChat() {
const { messages, sendMessage, status } = useChat({
transport: {
async sendMessages(options) {
return eventIteratorToUnproxiedDataStream(
await orpc.assistant.chat.call(
{ chatId: options.chatId, messages: options.messages },
{ signal: options.abortSignal },
),
);
},
reconnectToStream() {
throw new Error("Unsupported");
},
},
});
const [input, setInput] = useState("");
function submitPrompt(prompt: string) {
const value = prompt.trim();
if (!value || status !== "ready") return;
sendMessage({ text: value });
setInput("");
}
return (
<ChatThread status={status}>
<ChatThreadContent>
{messages.map((message) => (
<ChatThreadMessage key={message.id} message={message} />
))}
<ChatThreadActivity />
</ChatThreadContent>
<ChatComposer value={input} onValueChange={setInput} onSubmit={submitPrompt} status={status}>
<ChatComposerInput />
<ChatComposerToolbar>
<ChatComposerSubmit />
</ChatComposerToolbar>
</ChatComposer>
</ChatThread>
);
}ChatComposer keeps the textarea editable while the agent is responding, but submit is blocked until
status === "ready".