Pilotiq
DocsGitHub

@pilotiq-pro/ai

Commercial AI runtime for @pilotiq/pilotiq. Adds an AI chat sidebar (Anthropic / OpenAI / Google / Ollama / …), per-field AI quick actions (rewrite, shorten, fix-grammar, …), and a PilotiqAgent primitive for building custom AI flows inside resources.

#Why a separate package?

AI dependencies (@rudderjs/ai, model clients, streaming) add meaningful install weight and introduce billing surface that shouldn't land in every Pilotiq deployment. The separate package keeps the open-core install lean while letting pro users pull in the full agent runtime.

#Installation

pnpm add @pilotiq-pro/ai

You also need an AiProvider from RudderJS in your bootstrap providers, registered explicitly so the auto-discovery doesn't conflict:

// bootstrap/providers.ts
import { AiProvider } from '@rudderjs/ai/server'
import { defaultProviders } from '@rudderjs/core'

export default [
  ...(await defaultProviders({ skip: ['@rudderjs/ai'] })),
  AiProvider,
  // …other providers
]

@rudderjs/ai reads its config from config('ai') — define your model + provider list there:

// config/ai.ts
import { Env } from '@rudderjs/core'
import type { AiConfig } from '@rudderjs/ai'

export default {
  default: Env.get('AI_MODEL', 'anthropic/claude-sonnet-4-5'),
  models: [
    { id: 'anthropic/claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
    { id: 'openai/gpt-4o',               label: 'GPT-4o' },
  ],
  providers: {
    anthropic: { driver: 'anthropic', apiKey: Env.get('ANTHROPIC_API_KEY', '') },
    openai:    { driver: 'openai',    apiKey: Env.get('OPENAI_API_KEY',    '') },
  },
} satisfies AiConfig

#Dedupe @rudderjs/ai (load-bearing)

@rudderjs/ai exposes a static AiRegistry singleton populated by AiProvider.boot(). If pnpm extracts the package twice (typically because consumers' zod versions split the peer-dep hash), AiProvider populates one instance and the chat handler reads from another — chat throws [RudderJS AI] No default model set.

Pin both the package and the splitting peer in your root package.json:

{
  "pnpm": {
    "overrides": {
      "@rudderjs/ai": "1.5.0",
      "zod":         "^3.23.0"
    }
  }
}

Verify both consumers' node_modules/@rudderjs/ai symlinks point to the same .pnpm/... directory after pnpm install. (Same trap the open-core React + @pilotiq/pilotiq dedupe addresses, but on the server side — Vite dedupe doesn't help here.)

#Setup

Register the plugin on your panel (app/Pilotiq/AdminPanel.ts):

import { Pilotiq } from '@pilotiq/pilotiq'
import { ai } from '@pilotiq-pro/ai'
import prisma from './prisma.js'

export const adminPanel = Pilotiq.make('Admin')
  .resources([/* ... */])
  .plugins([
    ai({ prisma }),    // omit `prisma` for ephemeral chat (no persistence)
  ])

The ai() plugin's register(panel) step:

  1. Seeds the built-in AI action catalogue (rewrite / shorten / expand / fix-grammar / translate / summarize / make-formal / simplify) into the registry.
  2. Registers the AI chat panel as a right-sidebar contribution ('ai.chat').
  3. Augments Field.prototype.ai() so RichTextField.make('body').ai(['rewrite']) works in resource schemas.
  4. Registers the resource-header [✦ Agents ▾] dropdown via render-hooks.
  5. Registers four open-core seam contributions: registerFieldLabelSlot ( field trigger), registerPendingSuggestionOverlay (FieldShell diff card), the slot-component bag, and the chat sidebar's right-panel id.

registerRoutes(router, pilotiq) mounts the chat SSE endpoint + per-resource agent routes when pilotiq's host calls registerPilotiqRoutes(router, panel).

#Layout-level providers (auto-mounted)

The ai() plugin auto-registers <AiUiProvider> at the panel layout root via pilotiq core's Pilotiq.layoutProvider(...) API (≥0.6.5). This is the mount that makes the AI approval queue, FieldShell overlay, and update_form_state / read_form_state browser handlers reachable from every page in the panel — not just the chat sidebar tree.

You don't need to wrap your pages/+Layout.tsx manually. The plugin handles it.

If you're on an older pilotiq host (< 0.6.5) that doesn't expose layoutProvider, fall back to the explicit wrap:

// pages/+Layout.tsx (only on host versions < 0.6.5)
import '@/index.css'
import type { ReactNode } from 'react'
import { AiUiProvider } from '@pilotiq-pro/ai'

export default function Layout({ children }: { children: ReactNode }) {
  return (
    <AiUiProvider panelPath="/admin">
      {children}
    </AiUiProvider>
  )
}

AiUiProvider self-detects nested mounts, so the manual wrap above is also safe to leave in place after upgrading — the auto-mount sees the outer one and short-circuits to a no-op render.

#Plugin options

Option Type Default Notes
prisma PrismaClient Enables conversation persistence. Omit for ephemeral mode.
conversationStore ConversationStore Custom persistence backend. Wins over prisma.
middleware MiddlewareHandler[] [] Runs in front of every chat + agent route (session, auth, rate limit).
label string 'AI Assistant' Right-sidebar tab label.
icon string | null 'sparkles' Right-sidebar tab icon (registry name). null hides it.
defaultWidth number 360 Default open width in px for the chat panel.
models string[] Allow-list of model ids surfaced in the chat-panel model picker.

#AI chat sidebar

The chat panel mounts automatically as the 'ai.chat' right-sidebar contribution. Users open it via the topbar icon or Mod-Shift-\.

It can read the current form's values (read_form_state), run agent tools, and update fields (update_form_state / edit_text) — all without leaving the page.

#Conversation persistence

Pass prisma to persist conversation history across sessions:

ai({ prisma })

Conversation models (AiConversation + AiChatMessage) ship in @pilotiq-pro/ai's schema/ directory — copy into your app's prisma/schema/ and run prisma migrate. Without persistence, conversations are in-memory and the conversation switcher dropdown stays empty.

Custom backend:

import { ai, type ConversationStore } from '@pilotiq-pro/ai'

ai({ conversationStore: myStore })

#Field quick actions

After the plugin is installed, every field gains a .ai(actions) method. Call it in your resource schema to attach quick-action buttons:

import { RichTextField } from '@pilotiq/tiptap'
import { TextField } from '@pilotiq/pilotiq'

static override form(form: Form): Form {
  return form.schema([
    TextField.make('title').ai(['rewrite', 'shorten']),
    RichTextField.make('body').ai(['rewrite', 'shorten', 'fix-grammar']),
  ])
}

The actions appear as a small button next to the field label. Clicking an action runs it against the current field value via the agent endpoint, streams a response, and updates the field — without leaving the page.

#Built-in action slugs

Slug What it does
rewrite Rewrites the value while preserving meaning and approximate length
shorten Cuts to roughly half the length while keeping key points
expand Adds detail, examples, and context; keeps the original tone
fix-grammar Fixes grammar, spelling, and punctuation; preserves meaning
translate Translates to a target language (user specifies in the prompt)
summarize Condenses to 1–3 sentences
make-formal Rewrites in a more professional tone
simplify Uses plain language and shorter sentences

All built-ins apply to text-family fields: text, textarea, richtext, markdown.

#Custom actions

Pass a PilotiqAgent instance alongside slugs for domain-specific actions:

import { PilotiqAgent } from '@pilotiq-pro/ai'

const seoTitle = PilotiqAgent.make('seo-title')
  .label('Optimise for SEO')
  .icon('Search')
  .appliesTo(['text'])
  .instructions('Rewrite the {field} as a concise, keyword-rich SEO title under 60 characters.')

TextField.make('metaTitle').ai(['rewrite', seoTitle])

#Per-field approval override

Field.aiRequireApproval(b) overrides the agent-level approval policy for AI writes targeting this field. Useful when one "low-risk" field on an otherwise-gated form should be exempt — or vice versa.

TextField.make('title').ai(['rewrite']).aiRequireApproval(false)
TextField.make('legalNotice').ai(['rewrite']).aiRequireApproval(true)

Resolution chain (most-specific wins):

tool default  →  field-instance override  →  agent default

#PilotiqAgent API

PilotiqAgent is the primitive for all AI actions — both built-ins and custom ones.

import { PilotiqAgent } from '@pilotiq-pro/ai'

const agent = PilotiqAgent.make('slug')
  .label('Human label')
  .icon('Search')                     // lucide icon name
  .appliesTo(['text', 'richtext'])    // field types this action is valid for
  .instructions('System prompt. Use {field} for the field name.')

#Fluent setters

Method Description
.label(string) Display name in the UI.
.icon(string) Lucide icon name.
.instructions(string | fn) System prompt — static string or a function receiving the record. Use {field} to interpolate the targeted field name.
.fields(string[]) Form fields the auto-generated write tools (update_field / edit_text / update_form_state) may target. Empty for built-ins (scope set per-call).
.model(string) Override the AI model id (e.g. 'anthropic/claude-sonnet-4-5'). Falls through to config('ai').default.
.appliesTo([...]) Field-type allowlist when this agent is referenced from Field.ai([...]). Default ['*'] (any).
.proactive(b = true) Include in the chat orchestrator's auto-delegation pool. Default false — non-proactive agents only run on explicit @-mention or header-dropdown click.
.requireApproval(b = true) Pause server-tool calls for user approval before the agent loop runs them. Server tools only@rudderjs/ai doesn't gate client tools on the pending-approval path, so flagging update_form_state is a no-op. Use review mode (Pilotiq.aiSuggestionsMode('review')) for field-write consent. Tool default still wins; this only relaxes / tightens the agent default. See Consent & approval.
.examples([str]) Example prompts. Surface in the resource-header [✦ Agents ▾] dropdown (first 1–2 as a muted subline) AND in the chat orchestrator's ## Available Agents section so the model can match user phrasing.

#Resource-level agents

Register agents on a resource to let users run longer multi-step flows from any record's page (not tied to a specific field). Each agent surfaces in two places:

  1. [✦ Agents ▾] chip in the resource page header — a peer of Create / View / Delete / Save. Clicking opens a slim popover-chat scoped to that single sub-agent.
  2. Right-sidebar chat orchestrator — the chatbox can delegate to registered sub-agents via the run_agent tool (when the agent is .proactive(true)) or on explicit @<slug> mention.

The popover-chat surface is transient and isolated: closing it loses the conversation, and nothing spills into the right-sidebar thread. Sustained multi-turn work belongs in the sidebar chatbox.

import { PilotiqAgent } from '@pilotiq-pro/ai'

export class PostResource extends Resource {
  static override label = 'Posts'
  static override slug  = 'posts'

  static aiAgents() {
    return [
      PilotiqAgent.make('seo')
        .label('SEO Assistant')
        .icon('sparkles')
        .proactive()                 // chat orchestrator can auto-delegate
        .examples([
          'Audit SEO and suggest improvements',
          'Write a meta description for this post',
        ])
        .instructions('Help write SEO-optimised meta titles + descriptions.'),

      PilotiqAgent.make('translate')
        .label('Translator')
        .icon('languages')
        .instructions('Translate the post body into the requested language.'),
    ]
  }
}

The header chip auto-mounts on every resource role (list / create / edit / view) where the resource declares aiAgents(). On list / create pages — where there's no record context yet — the popover-chat notes that the agent needs a record to operate on. Edit / view pages thread the active recordId so the agent can read + write the record via the standard toolkit.

#Default toolkit

Every PilotiqAgent ships with five tools:

  • update_field (server) — direct field write via @rudderjs/sync (Yjs). Headless-only — for cron jobs and background runs.
  • read_record (server) — returns the current record as JSON.
  • edit_text (server) — rope edit on a field's persisted value via Y.Doc. Collab fields only; sees user's unsaved local edits when a browser is listening.
  • update_form_state (client) — dispatches form-state ops to the live <SchemaForm> in the user's browser. Use this when a browser is open — it preserves unsaved local edits and works for non-collaborative fields. v1 implements set_value only — other ops (rewrite_text / format_text / block ops) return a clear error so the agent routes to edit_text (collab text) instead.
  • read_form_state (client) — reads field values from the live form state, including unsaved edits.

Client tools (update_form_state / read_form_state) round-trip through the server: the agent loop pauses with pending_client_tools, the browser executes the handler from ClientToolRegistry (registered automatically by <AiClientToolBindings> mounted inside <AiUiProvider>), and POSTs the result back via the chat or standalone agent continuation endpoint.

@pilotiq-pro/ai ships two independent consent mechanisms. They sit at different layers of the agent run and are often combined:

Mechanism Setter Layer Affects
Review mode Pilotiq.aiSuggestionsMode('review') client-side, post tool-call Client write tools (update_form_state, edit_text for collab)
Tool approval agent.requireApproval(true) / per-tool needsApproval: true server-side, pre tool-call Server tools only

Pick by question: "do you want to show the user a diff before changing a field?" → review mode. "do you want to pause the agent before it runs a side-effecting server action?"requireApproval.

Pilotiq.aiSuggestionsMode('auto' | 'review') switches how AI-driven field writes apply.

// app/Pilotiq/AdminPanel.ts
export const adminPanel = Pilotiq.make('Admin')
  .aiSuggestionsMode('review')   // default 'auto'
  .plugins([ai({ prisma })])

In review mode, update_form_state (and the collab edit_text rope ops) doesn't write to the field. It pushes a PendingSuggestion onto the PendingSuggestionsContext queue and returns "Staged for user review" to the agent. The field renders an overlay-as-input with the original value, the suggested value, and Approve / Reject chips. Approving runs the registered applier; rejecting drops it. Either way, agent run continues as normal — the model thinks its write succeeded.

In auto mode, the same write goes straight to the applier registry and updates the form immediately. No overlay, no queue.

The same queue surfaces in three places when review mode is on:

  1. Inline chip on rich-text fields — strikethrough on the original range + replacement preview + per-hunk Approve / Reject. Powered by @pilotiq/tiptap's AiSuggestionExtension.
  2. FieldShell overlay-as-input on every other field — current vs suggested value (with per-fieldType comparison renderers — Select option labels, Boolean On/Off chips, Color swatches, Tags chip lists, word-level inline diff for plain text) + Approve / Reject icon-only chips. Default UI shipped by <PendingSuggestionOverlay>.
  3. Chat-sidebar pending pill — aggregate Pending suggestions (N) badge above the chat input. Click to expand a list grouped by field with per-row Approve / Reject and footer Approve all / Reject all.

Approving in any place clears the suggestion everywhere — every surface reads from one shared queue, and approval routes through a cross-tree applier registry so the chat sidebar can mutate fields outside its own React tree.

For ecosystem authors building custom field renderers or alternate overlay UIs, the open-core queue + applier seam is documented at Pro › AI Suggestions. The behavior above is automatic for the typical pilotiq install — there's nothing to configure beyond the .aiSuggestionsMode(...) setter.

#2. Tool approval — server-tool pre-call pause

For irreversible server-side actions — flagging a post for review, publishing, deleting a record, sending email, charging a card — you usually want the user to confirm before the agent loop runs the tool. Use needsApproval: true on the tool definition (or agent.requireApproval(true) to apply it to every auto-generated write tool the agent owns).

import { toolDefinition } from '@rudderjs/ai'
import { z } from 'zod'
import { PilotiqAgent } from '@pilotiq-pro/ai'

const flagForReview = toolDefinition({
  name:          'flag_for_review',
  needsApproval: true,
  description:   'Flag this post for editorial review with a short note.',
  inputSchema:   z.object({ note: z.string() }),
}).server(async ({ note }) => {
  // your server side-effect — DB write, email, queue job, …
  return `Flagged for review: ${note}`
})

PilotiqAgent.make('seo')
  .label('SEO Assistant')
  .tools([flagForReview])
  .instructions('… When the user asks to flag, mark, or submit the post for review, call `flag_for_review` …')

When the model picks flag_for_review:

  1. @rudderjs/ai's agent loop sees needsApproval: true and emits pending-approval instead of running the tool.
  2. The server forwards a tool_approval_required SSE event with the proposed tool args.
  3. The browser renders an approval card (amber, sparkle icon, tool name, JSON args, Approve / Reject buttons) inline in whichever surface launched the run.
  4. Approve → continuation request POSTs approvedToolCallIds: [id] → server resumes the loop and runs the tool. Reject → rejectedToolCallIds: [id] → tool execution skipped.

Three approval-card surfaces, all reading the same SSE event:

Surface Trigger Component
Chat sidebar Right-panel chat AiChatPanel (inline approval_request message part)
Header agents popover [✦ Agents ▾] → agent AgentPopoverChat
Per-field dropdown Field.ai([...]) → action AiDropdown

All three render <ApprovalCardUI> and route Approve / Reject through their own state owner — chat uses AiChatContext.approvePending / rejectPending; the standalone surfaces use useAgentRun.approve(toolCallId) / .reject(toolCallId).

#Why client tools can't use needsApproval

@rudderjs/ai's agent loop hardcodes isClientTool: false on the pending-approval path — the client-tool branch fires earlier in the loop with client_tool_calls and bypasses approval entirely. Practically: stamping needsApproval: true on update_form_state or any other client tool is a silent no-op. That's by design — the client-tool round-trip already requires the browser to participate, and review mode is the right consent surface for field writes (you want a diff, not a JSON args dump).

#Per-field override

Field.aiRequireApproval(b) overrides the agent-level approval policy for AI writes targeting this field's auto-generated tools. Useful when one "low-risk" field should escape an otherwise-gated form — or vice versa.

TextField.make('title').ai(['rewrite']).aiRequireApproval(false)
TextField.make('legalNotice').ai(['rewrite']).aiRequireApproval(true)

Resolution chain (most-specific wins): tool default → field-instance override → agent default. Same caveat as above — server tools only; client tools ignore the flag.

#Combining both

auto mode + a server tool with needsApproval: true → field writes apply immediately; server actions pause. Most apps want this.

review mode + a server tool with needsApproval: true → field writes stage as suggestions for in-place Approve/Reject; server actions pause with the popover/chat approval card. Belt-and-suspenders — appropriate when you want every AI side-effect (form-level and server-level) gated behind an explicit user click.

review mode alone (no server tools, no requireApproval) → only field-write consent. The most common config for content-editor panels.

auto mode alone (no requireApproval) → AI writes go through the moment the agent calls a write tool. Best for trusted internal tooling.

#Demo

The pilotiq-pro playground exercises both gates on PostResource:

  • Pilotiq.aiSuggestionsMode('review') in playground/app/Pilotiq/AdminPanel.ts → SEO Assistant title / body rewrites stage as suggestions on the field.
  • flag_for_review server tool in playground/app/Pilotiq/Posts/PostResource.ts → "Flag this post for review" prompt triggers the popover approval card.

#TypeScript augmentation

@pilotiq-pro/ai ships module augmentations that add .ai() and .aiRequireApproval() to Field, plus aiActions and aiRequireApproval to FieldMeta. They activate automatically when you import from @pilotiq-pro/ai anywhere in your TypeScript project.

If you see Property 'ai' does not exist on type '...', add a project-local declaration shim (e.g. src/pilotiq-ai.d.ts):

export {}
declare module '@pilotiq/pilotiq' {
  interface Field {
    ai(actions: string[]): this
    aiRequireApproval(b?: boolean): this
  }
}

This is a pnpm peer-dependency deduplication issue — both files target @pilotiq/pilotiq but the package manager resolved two separate instances. The project-local declaration fixes it without touching the dependency tree.