Tulip Logo IconTulip
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".

On this page