---
title: "AI configuration and secrets"
description: "One configuration object for module options, server and browser, the Model presets and Model roles, and why API keys come from the environment only."
canonical_url: "https://skaile-ai.github.io/forge-editor/guides/ai-configuration"
---
# 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.

```ts
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):

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Default
    </th>
  </tr>
</thead>

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

## Keys come from the environment only

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

<table>
<thead>
  <tr>
    <th>
      Variable
    </th>
    
    <th>
      Sets
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        NUXT_TIPTAP_EDITOR_AI_API_KEY
      </code>
    </td>
    
    <td>
      <code>
        ai.apiKey
      </code>
      
      : shared fallback for every Model preset without its own key
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        NUXT_TIPTAP_EDITOR_AI_PRESETS_<ID>_API_KEY
      </code>
    </td>
    
    <td>
      <code>
        ai.presets.<id>.apiKey
      </code>
      
      ; <code>
        <ID>
      </code>
      
       is the preset ID in upper snake case (<code>
        flashLite
      </code>
      
       → <code>
        NUXT_TIPTAP_EDITOR_AI_PRESETS_FLASH_LITE_API_KEY
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        NUXT_TIPTAP_EDITOR_AI_TRANSCRIPTION_API_KEY
      </code>
    </td>
    
    <td>
      <code>
        ai.transcription.apiKey
      </code>
      
       (only effective when <code>
        ai.transcription
      </code>
      
       is set)
    </td>
  </tr>
</tbody>
</table>

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


## Sitemap

See the full [sitemap](https://skaile-ai.github.io/forge-editor/sitemap.md) for all pages.
