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.8.3",
      "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'

export const adminPanel = Pilotiq.make('Admin')
  .resources([/* ... */])
  .plugins([
    ai(),    // chat is ephemeral until a conversation store is bound — see Conversation 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
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.
license false | { perRunCostCents?, client? } { enabled: true, perRunCostCents: 5 } License verification + per-run credit metering. false skips it entirely. See License & metering.

#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

Without persistence, chat still works — conversations are in-memory and the conversation switcher dropdown stays empty. To persist history across sessions, bind a ConversationStore under the 'ai.conversations' container key in a service provider.

Persistence is wired in a provider — not as a plugin option — because the panel module (where ai() is called) is bundled for the client too; database clients don't belong in it.

Native engine / any rudder 'db' adapter:

// app/Providers/AppServiceProvider.ts
import { ServiceProvider } from '@rudderjs/core'
import { DatabaseConversationStore } from '@pilotiq-pro/ai/server'

export class AppServiceProvider extends ServiceProvider {
  register(): void {
    // Self-resolves the app's 'db' adapter per request — no constructor args.
    this.app.singleton('ai.conversations', () => new DatabaseConversationStore())
  }
}

The store reads and writes two tables, aiConversation and aiChatMessage. Create them with a migration:

import { Migration, Schema } from '@rudderjs/database'

export default class extends Migration {
  async up() {
    await Schema.create('aiConversation', (t) => {
      t.string('id').primary()
      t.string('userId').nullable()
      t.string('title').default('New conversation')
      t.string('resourceSlug').nullable()
      t.string('recordId').nullable()
      t.timestamps()
      t.index(['userId', 'updatedAt'])
      t.index(['resourceSlug', 'recordId', 'updatedAt'])
    })

    await Schema.create('aiChatMessage', (t) => {
      t.string('id').primary()
      t.string('conversationId')
      t.string('role')
      t.text('content')
      t.text('toolCalls').nullable()        // JSON-encoded tool_use blocks
      t.string('toolCallId').nullable()
      t.dateTime('createdAt').nullable()
      t.index(['conversationId', 'createdAt'])
    })
  }
}

(Table names are configurable — new DatabaseConversationStore({ conversationTable, messageTable }).)

Legacy Prisma apps: bind PrismaConversationStore instead. It resolves the Prisma client from the 'prisma' container key and persists to AiConversation + AiChatMessage models with the same columns as above.

import { PrismaConversationStore } from '@pilotiq-pro/ai/server'

this.app.singleton('ai.conversations', () => new PrismaConversationStore())

Custom backend: bind any object implementing ConversationStore:

this.app.singleton('ai.conversations', () => myStore)

The built-in stores scope conversations by userId, so the chat routes need session + auth middleware — wire it via ai({ middleware: [...] }). A per-user store mounted with no middleware fails loudly at boot instead of leaking conversations across users.

#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

#License & metering

@pilotiq-pro/ai verifies a pilotiq.io license and meters credits per agent run via the bundled @pilotiq-pro/license-client. Configure it with the license plugin option:

ai({ license: false })                           // skip verification + metering entirely
ai({ license: { perRunCostCents: 10 } })         // override the per-run debit
ai({ license: { client: myLicenseClient } })     // inject a pre-built LicenseClient (tests)
ai()                                             // default: enabled, 5¢/run, env-bound client
  • license: false — skips the gate. Useful for self-host installs where the customer pays Anthropic / OpenAI for tokens directly and the credit ledger isn't relevant.
  • license: { perRunCostCents?, client? }perRunCostCents overrides the per-agent-run debit (default 5, from internal/license-config.ts); client injects a pre-built LicenseClient. When omitted, the env-bound getLicenseClient() singleton is used, reading PILOTIQ_LICENSE_TOKEN at the first agent run.
  • Omitted (default) — enabled with perRunCostCents: 5.

Each agent run calls runLicenseGate, which:

  1. verify()s the license,
  2. checks the 'agents' feature flag, then
  3. debit()s perRunCostCents with reason 'agent_run'.

Failures reject the run with an HTTP status:

Status Cause
401 Invalid token (verify or debitinvalid-token).
402 No token, other verify failures, feature not licensed, or credits exhausted.
403 License inactive (debitlicense-inactive).
500 Unexpected debit failure.

(Exact reason → status table in packages/license-client/README.md.)

When perRunCostCents <= 0 (or license: false), the debit step is skipped.

For the client's caching, stale-reuse, idempotency, retry/backoff, and env-var semantics, see packages/license-client/README.md.

#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()])

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.

Resolution order

.aiSuggestionsMode(...) can be set in three places. When the AI writes to a field, the resolver (AiClientToolBindings.readSuggestionMode) picks the most specific value:

# Source Setter When it wins
1 Active agent PilotiqAgent.make('rewrite').aiSuggestionsMode('review') The agent that's currently dispatching overrides everything below
2 Field instance TextField.make('title').aiSuggestionsMode('review') Read from a data-ai-suggestions-mode DOM marker on the field's wrapper
3 Panel-wide default Pilotiq.make('Admin').aiSuggestionsMode('review') Read from window.__pilotiqAiSuggestionsMode
4 Built-in fallback 'auto'

Set the panel-wide default for a project-wide policy ("show diffs on AI writes"), tighten a single field with the field setter ("always review changes to this column"), and tighten a single agent with the agent setter ("this rewrite tool always stages — even when the panel is auto"). Most installs only set the panel-wide value.

The chat-orchestrator path (multi-agent server runs) follows the same precedence — each sub-agent's mode is resolved on dispatch, so a review override on one agent doesn't leak to its siblings.

#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.