Guides

AI configuration and secrets

One configuration object for module options, server and browser, the Model presets and Model roles, and why API keys come from the environment only.

Defining the configuration

One configuration, three views (@skaile/forge-editor/config, framework-free). defineEditorConfig(input) applies every default below once and returns the EditorConfig; publicConfig(config) is the part the browser may see (locale, messages, ai: { roles, transcription: boolean }, the createEditor({ ...publicConfig }) options), serverConfig(config) the part the server needs, keys included (ai: { presets, apiKey, slots, transcription? }, rateLimit, collab: spread it into installEditorServer(nitroApp, { ports, ...serverConfig(config) })). The Nuxt module does exactly that: its options are EditorConfigInput, runtimeConfig.tiptapEditor = serverConfig(config) and runtimeConfig.public.tiptapEditor = publicConfig(config), and the server plugin passes the runtime config on to installEditorServer. Every Model preset, the shared key and the transcription get an empty apiKey in the server view, so the NUXT_TIPTAP_EDITOR_AI_* variables below have a key to override. An unset value (locale: process.env.EDITOR_LOCALE) means the default.

import { defineEditorConfig, publicConfig, serverConfig } from '@skaile/forge-editor/config'

const config = defineEditorConfig({ locale: 'en', ai: { roles: { rewrite: 'flashLite' } } })
serverConfig(config) // { ai: { presets, apiKey: '', roles, … }, rateLimit: { max: 30, windowMs: 60000 }, collab: { authenticateUpgrade: false } }
publicConfig(config) // { locale: 'en', ai: { roles, transcription: false } }

Options

Config key tiptapEditor (EditorConfigInput; a Model preset with the ID of a default overrides single fields of it):

OptionTypeDefault
locale'de' | 'en': language of every string the editor renders; passed to the client context plugin (createEditor({ locale }))'de'
messagesPartial<Record<MessageKey, string>>: overrides of single messages by key (JSON-serialisable)none
ai.presetsRecord<string, ModelPresetConfig> ({ label, endpoint, model, apiKey? }): server Model presets keyed by preset ID (camelCase). endpoint is any OpenAI-compatible chat-completions base URL.sonnet, flashLite (OpenRouter)
ai.rolesRecord<ModelRole, string> ('complete' | 'rewrite' | 'generate'): default Model preset per Model role. The complete model must support assistant prefill.{ complete: 'flashLite', rewrite: 'sonnet', generate: 'sonnet' }
ai.rateLimit{ max, windowMs } per user, shared by all AI endpoints{ max: 30, windowMs: 60000 }
ai.transcription{ provider?, endpoint, model, apiKey?, language? }: provider 'openai' (default) = OpenAI-compatible POST <endpoint>/audio/transcriptions (verbose_json segments), 'deepgram' = Deepgram POST <endpoint>/listen (endpoint https://api.deepgram.com/v1, model e.g. nova-3; utterances become the segments). language (e.g. 'de') fixes the spoken language; without it OpenAI guesses and Deepgram detects itabsent: action hidden, endpoint answers 501
collab.authenticateUpgradeboolean: authenticate the /collab/:id upgrade and refuse it with HTTP 401 (see Auth notes); env NUXT_TIPTAP_EDITOR_COLLAB_AUTHENTICATE_UPGRADEfalse: upgrade first, close with 4401

Keys come from the environment only

Never put a key into nuxt.config.ts (it ends up in the build output).

VariableSets
NUXT_TIPTAP_EDITOR_AI_API_KEYai.apiKey: shared fallback for every Model preset without its own key
NUXT_TIPTAP_EDITOR_AI_PRESETS_<ID>_API_KEYai.presets.<id>.apiKey; <ID> is the preset ID in upper snake case (flashLite → NUXT_TIPTAP_EDITOR_AI_PRESETS_FLASH_LITE_API_KEY)
NUXT_TIPTAP_EDITOR_AI_TRANSCRIPTION_API_KEYai.transcription.apiKey (only effective when ai.transcription is set)

Host models through the adapter

A model provider (ModelProvider, the models port) with languageModel(ref, user) is asked first for every Model reference; a returned model is used as is (no Model preset, no key needed), null falls through to the Model presets above. listModels(user) decides what GET /api/ai/models offers (return null to list the Model presets). Host-driven agent chats are the counterpart on the client: createAgentOpReview({ notify, t }) from @skaile/forge-editor/ai shows agent edit operations from any transport as in-document hunks (propose), follows the user's decisions (state, decide, reveal) and reads them back for the agent (feedback() yields each decision once); createAiSidebarFlow({ send, assistant, notify, editor }) takes the host's send(question, scope) instead of a chat.

Key resolution and errors

A Model preset resolves its key as preset.apiKey || ai.apiKey. A missing key answers 500 naming both variables before any stream starts; an unknown preset ID answers 400; upstream failures answer 502.

Where the secrets live

runtimeConfig.tiptapEditor.ai is server-only. The browser only ever receives public.tiptapEditor.ai = { roles, transcription: boolean } and, through GET /api/ai/models, Model profiles of the Model presets without keys. A user-defined Model profile (kept in the browser's localStorage, key tiptap-editor:model-profiles) sends its own key with each request; the server forwards it and never stores it.

Copyright © 2026