Skip to main content
The ai-chat plugin adds a working AI chat to your app: multi-conversation history, per-user ownership, and Google Gemini responses through the Vercel AI SDK. Like every Ship plugin, it merges into your codebase — two resources on the API, a route tree on the web, and a tiny @ship/ai package you own and can edit.

Requirements

  • PostgreSQL + TanStack Start (full-stack). Chats and messages are stored with Drizzle in PostgreSQL.
  • The Auth plugin (auth-starter). Every endpoint runs behind isAuthorized, and the web pages live inside the authenticated app shell.

Install

Pick ai-chat (and auth-starter) in the plugin multiselect when you scaffold:
To run an existing project against the plugin sources in dev:
Then add your Google AI key to the API .env:
Get one at Google AI Studio.

What merges in

Storage is PostgreSQL

Two tables, both built on baseColumns (a uuid id plus createdAt / updatedAt / deletedAt), so they get soft-delete for free. ai_messages.chatId cascades on delete.
After the schema files merge in, regenerate the typed DbService and the migration:

The AI package

@ship/ai is a small package (packages/ai) wrapping the Vercel AI SDK. It exposes one function, generateResponse, which sends the conversation to Gemini (gemini-2.5-flash) and returns the final text. It reads GOOGLE_GENERATIVE_AI_API_KEY from the environment, and trims context to the last 50 messages.
Swap the model — or wire up a different provider — with configureAi:

Endpoints are oRPC

Every endpoint builds on the shared @/endpoint builder, runs behind isAuthorized, and declares an explicit route. Reads and writes go through the typed db.aiChats / db.aiMessages services. send-message is where it comes together: save the user message, load the full thread, ask Gemini, persist the reply, and auto-title the chat on the first turn.
The handler returns both messages in one response, so the client has the persisted ids and the assistant reply without a second round-trip. After the endpoint files merge in, regenerate the router and contract:

Ownership is a gate, not an if

The canEditChat middleware loads the requested chat for the current user into context.chat or throws NOT_FOUND. Because access and existence collapse into one error, an unauthorized user can’t tell whether a chat exists.
list and create only need isAuthorized; get-messages, send-message and remove stack canEditChat on top. See How Ship works for the full gate model. Every route shows up live in the Scalar API reference at http://localhost:3001/docs.

The web side

The chat UI is a pair of TanStack Router routes under the authenticated app shell:
  • /app/ai-chat — start a new conversation
  • /app/ai-chat/$chatId — an existing one
Both render the same AiChatPage component. It talks to the API through the typed oRPC client and the Auth plugin’s useApiMutation hook — no hand-written fetch, fully typed end to end:
The page sends the message optimistically (a temp- placeholder), then swaps in the persisted user and assistant messages from the response. The presentational pieces — AiChatBox, AiChatMessage, AiChatInput, AiMessageSkeleton — live in the router-ignored -components/ folder, so they’re yours to restyle.
Everything that merges in is ordinary Ship code. Want streaming tokens, tool calls, or a different provider? It’s generateResponse in packages/ai and one oRPC endpoint — edit them like any other resource.