Building app AI
This guide is for agents and authors adding an AI surface (chat composer + tool-driven actions) to a Hudson app. The pattern is two files: one server-side toolset, one client-side hook. Once you know the shape, copy the reference app that's closest to what you're building and edit.
Working examples to copy from, simplest first:
apps/web/app/apps/hudson-ai/— model/provider settings, chat surface, and workspace-level AI affordances.apps/web/app/api/ai/toolsets/workspace.ts— shell/workspace tools, including service-backed actions.apps/web/app/api/ai/toolsets/intents.ts— intent-catalog tools that bridge AI to live app commands.
Mental model
The server defines what the AI can do (system prompt, tool schemas). The client defines what to do with results (apply state changes when a tool fires). The two halves meet at one string: the toolset id.
client server
useFooAI(opts) POST /api/ai/chat route.ts
↓ context = { ... } ───────────────────→ ↓
useHudsonAI({ createPiAiBackend().streamUI({
toolset: 'foo', ... messages, toolset: 'foo', ...
onToolCall: ... })
}) ↓
defaultRegistry.resolve('foo')
↓
{ system, context(ctx), tools(ctx) }
↓
pi-ai stream → tool_call
text-delta / ←──────────────────────
tool-input-available
↓
onToolCall('set_x', { ... }) → app state changes
The route, the backend, the multi-step tool loop — none of that is your concern. You write a server toolset and a client hook.
File layout
For a new app foo:
apps/web/app/api/ai/toolsets/foo.ts ← server: system prompt, context renderer, tool schemas
apps/web/app/apps/foo/useFooAI.ts ← client: useHudsonAI() wrapper + onToolCall router
apps/web/app/api/ai/toolsets/index.ts ← add one line: defaultRegistry.register('foo', fooToolset)
Naming conventions:
- Toolset id matches app id (
foo↔apps/web/app/apps/foo/). - Hook is
useFooAI(PascalCase app name). - Toolset export is
fooToolset(lowercase, matches id).
Server side — the toolset
A ToolsetDefinition has three parts: system, context(ctx), tools(ctx). The shape is enforced by import type { ToolsetDefinition } from '@hudsonkit/ai/toolsets'.
// apps/web/app/api/ai/toolsets/foo.ts
import { tool } from 'ai';
import { z } from 'zod';
import type { ToolsetDefinition } from '@hudsonkit/ai/toolsets';
// ─── System prompt ──────────────────────────────────────────────────────────
// Static. Personality, taste, capability docs. Doesn't change per request.
const system = `You are a [role] working in [app]. [Voice / taste constraints.]
## How this works
[How the app surfaces work + what tools do.]
## Design rules
- Never use purple. Prefer cyan, teal, emerald.
- [Other taste constraints from CLAUDE.md.]
## Response style
- Brief. After tool calls, summarize what changed in 1-2 sentences.
- Don't narrate. Don't apologize.`;
// ─── Context renderer ───────────────────────────────────────────────────────
// Dynamic. Recomputed every request from whatever the client puts in `context`.
function context(ctx: Record<string, unknown>): string {
const c = ctx as { items?: unknown[]; mode?: string };
const sections: string[] = [];
if (c.mode) sections.push(`## Mode\n${c.mode}`);
if (c.items?.length) {
sections.push(`## Items (${c.items.length})\n\`\`\`json\n${JSON.stringify(c.items, null, 2)}\n\`\`\``);
}
return sections.join('\n\n');
}
// ─── Tools ──────────────────────────────────────────────────────────────────
// Zod-validated schemas. The `execute` callback runs server-side but is almost
// always an echo — the real mutation happens client-side via `onToolCall`.
function tools(_ctx: Record<string, unknown>) {
return {
set_mode: tool({
description: 'Switch the app into a different mode.',
inputSchema: z.object({
mode: z.enum(['draft', 'review', 'published']).describe('Target mode'),
}),
execute: async (args) => ({ applied: true, ...args }),
}),
add_item: tool({
description: 'Add a new item to the current list.',
inputSchema: z.object({
title: z.string().describe('Display name'),
priority: z.number().int().min(1).max(5).optional().describe('1=low, 5=high'),
}),
execute: async (args) => ({ applied: true, ...args }),
}),
};
}
export const fooToolset: ToolsetDefinition = { system, context, tools };
Register it once in apps/web/app/api/ai/toolsets/index.ts:
import { fooToolset } from './foo';
defaultRegistry.register('foo', fooToolset);
Tool design rules
- Schemas with
.describe()everywhere. The model reads these. Don't skip them. - Tool names are snake_case verbs:
set_param,add_item,delete_template. NeversetParamorItem.add. - One responsibility per tool. If you find yourself writing
do_thingwith a switch inside, split it. executeis usually an echo, not the action. Return{ applied: true, ...args }(or a small status object). The actual state change usually happens in the client'sonToolCall. The exception is work that genuinely belongs server-side: service execution, database writes, compilation, or fetches.- Use
z.enumoverz.stringwhen there's a fixed list. The model gets clearer guidance and you get validation. - Optional params are optional. Don't force the model to always pass everything.
System prompt voice
Match the existing apps. They follow this skeleton:
You are a [role specific to the app].
## How this works
[1-2 paragraphs explaining the app surface + what tools do.]
## [Domain rules — taste, parameters, constraints]
[Bullets. Specific. Concrete examples.]
## Response style
- [Brevity constraints]
- [What to do after tool calls]
Hudson-global constraints to include in every prompt:
- "Never use purple. Prefer cyan, teal, emerald."
- "Less is more — remove before adding."
- "Optical corrections over mathematical perfection."
Don't make the model recite these — embed them in the taste section so they shape every decision.
Client side — the hook
The hook glues app state to useHudsonAI. Three things you control: context, onToolCall, and (optionally) attachments.
// apps/web/app/apps/foo/useFooAI.ts
'use client';
import { useCallback, useMemo, useState, useEffect } from 'react';
import { useHudsonAI } from 'hudsonkit';
import type { AIAttachment, AppSettingsValues } from 'hudsonkit';
interface UseFooAIOptions {
// App state the AI can read
items: Item[];
mode: Mode;
// App setters the AI can drive (via onToolCall)
setMode: (mode: Mode) => void;
addItem: (item: Item) => void;
// App settings for provider/model selection
appSettings: AppSettingsValues;
}
export interface AiActivityEntry {
id: number;
tool: string;
summary: string;
timestamp: number;
}
let _activityId = 0;
export function useFooAI(opts: UseFooAIOptions) {
const { items, mode, setMode, addItem, appSettings } = opts;
// Activity log — surfaces "what did the AI just do" in the UI
const [activity, setActivity] = useState<AiActivityEntry[]>([]);
const logActivity = useCallback((tool: string, summary: string) => {
setActivity(prev => [...prev.slice(-9), { id: ++_activityId, tool, summary, timestamp: Date.now() }]);
}, []);
// What the AI sees — sent with every request. Wrap in useMemo so the
// transport's body() function reads stable references.
const context = useMemo(() => ({ items, mode }), [items, mode]);
// Opt-in extras the user can toggle from the chat composer
const attachments: AIAttachment[] = useMemo(() => [
{
label: 'Selection',
content: () => {
const el = document.querySelector('[data-selected]');
return el ? el.outerHTML : null;
},
},
], []);
const chat = useHudsonAI({
toolset: 'foo', // ← matches server registration
chatId: 'foo-app-chat', // ← stable id; persists across remounts
context,
attachments,
provider: String(appSettings.aiProvider || 'copilot'),
model: String(appSettings.aiModel || 'gemini-3-flash-preview'),
onToolCall: async (name, args) => {
try {
switch (name) {
case 'set_mode':
setMode(args.mode as Mode);
logActivity('set_mode', String(args.mode));
break;
case 'add_item':
addItem({ id: crypto.randomUUID(), title: args.title as string, ... });
logActivity('add_item', `Added "${args.title}"`);
break;
}
} catch (err) {
logActivity('error', `${name}: ${err instanceof Error ? err.message : String(err)}`);
}
},
});
// Surface chat errors in the activity log
useEffect(() => {
if (chat?.error) logActivity('error', String(chat.error).slice(0, 80));
}, [chat?.error, logActivity]);
return {
sendAiMessage: (message: string) => chat.sendMessage({ text: message }),
aiStatus: chat?.status ?? 'ready',
aiMessages: chat?.messages ?? [],
aiActivity: activity,
aiError: chat?.error ? String(chat.error) : null,
aiChat: chat, // full handle for surfaces using <AI/>
};
}
Client patterns to follow
useMemooncontext. It's sent on every request; identity churn = wasted re-renders.attachments≠context. Attachments are user-toggled (chip in the composer). Use them for big payloads (current SVG, file dump) the user might NOT want sent every turn.onToolCalldoes the actual work, notexecute. Centralize app-state mutations here.- Coerce values defensively. Some models (notably MiniMax) send string-encoded numbers (
"20"not20). Coerce insideonToolCallfor numeric params. - Log activity. Users want to see "what did it just do". The 10-entry rolling log pattern (
prev.slice(-9)) is the convention. - Wrap
chat.sendMessagein your own helper if you want logging or pre/post hooks.
Provider / model selection
Default to whatever the app's settings say:
provider: String(appSettings.aiProvider || 'copilot'),
model: String(appSettings.aiModel || 'gemini-3-flash-preview'),
appSettings.aiProvider and appSettings.aiModel come from the app's declarative settings schema (see docs/settings.md). If your app doesn't expose these as user-tunable settings, hardcode sensible defaults — but pick copilot + a Gemini-flash model unless you have a specific reason. They're fastest and cheapest for tool-driven work.
Available providers today (see apps/web/app/api/ai/providers.ts):
copilot(GitHub Copilot, OAuth via OpenCode auth.json)anthropic,openai,groq,xai,google,github,minimax
The backend handles credential resolution; you just pass the provider id.
Verification
After wiring the two files + the registration line:
# 1. Type-check
npx tsc --noEmit
# 2. Tests (if you added any)
npx vitest run
# 3. Smoke the endpoint (dev server on 3500)
curl -sS -X POST http://localhost:3500/api/ai/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"id":"u1","role":"user","parts":[{"type":"text","text":"hello"}]}],"toolset":"foo","context":{},"mode":"api"}' \
--max-time 30 | head -10
# Should see: data: {"type":"start"}, text-start, text-delta, [DONE]
# 4. Trigger a tool call
curl -sS -X POST http://localhost:3500/api/ai/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"id":"u1","role":"user","parts":[{"type":"text","text":"switch to review mode"}]}],"toolset":"foo","context":{"mode":"draft"},"mode":"api"}' \
--max-time 30 | grep -E "tool-input|text-delta" | head -5
In the running app, open the AI composer and ask the model to do something covered by your tools. Watch the activity log update.
Common pitfalls
- Forgot to register the toolset.
loadToolset('foo', ctx)returns{tools: {}, system: undefined}silently. Symptom: model responds chatty but never calls tools. - Tool schemas without descriptions. Model invokes tools but with wrong arg shapes. Add
.describe()to every field. contextnot memoized. Identity churn → request body churn. Symptom: weird state-mid-request bugs.- Trying to read app state in the toolset's
tools(ctx).ctxis the request's context object only. If you need app state, put it incontexton the client side. - Returning state from
execute. It echoes to the client astool-output-available, but apps don't typically read that. Mutate viaonToolCall.
Future: declarative ai: hudAI({...})
The current pattern requires a hook file + a toolset file + a registration line. The brief (HUD-006 step 5) envisions collapsing this into a single field on HudsonApp:
// future
import { hudAI } from 'hudsonkit';
export const fooApp: HudsonApp = {
id: 'foo',
name: 'Foo',
ai: hudAI({ toolset: 'foo', backend: 'pi-ai' }),
// ...
};
Until that lands, the two-file pattern above is the path. The convention is stable — the future migration will be mechanical.