Skip to content
Froala Documentation

AI Chat Assistant: Quick Start Guide

Get the AI Chat panel answering a question, with your API key kept safely on a small server rather than in the browser.

What you need first

  • Node.js 18 or newer
  • A code editor
  • An API key from an AI provider

Why Node? Only to hold your API key. If your key sits in the HTML file, anyone who opens DevTools on your site can read it and spend your money. The server is the locked drawer you keep the key in.

Step 1: Make a project folder and install two packages

mkdir froala-ai-demo
cd froala-ai-demo
npm init -y
npm install express cors

What this does: express is a tiny web server. cors lets your HTML page (opened on one address) talk to your server (running on another) without the browser blocking it. No AI SDK needed, because we'll call the provider with plain fetch, which Node 18+ already has built in.

Step 2: Create the server file

Create a file called server.js and paste this in:

// server.js
const express = require('express');
const cors = require('cors');
const app = express();

app.use(cors()); // allow the browser page to call this server
app.use(express.json({ limit: '25mb' })); // uploaded file context can be large

// 👇 Put your real key here, or better: read it from an environment variable
const API_KEY = process.env.GROQ_API_KEY || 'paste-your-key-here';

app.post('/ai', async (req, res) => {
  try {
    // Froala sends us the user's question in req.body.prompt
    const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${API_KEY}`
      },
      body: JSON.stringify({
        model: 'llama-3.1-8b-instant',
        messages: [{ role: 'user', content: req.body.prompt }]
      })
    });

    const data = await response.json();

    // Send just the answer text back to the editor
    res.json({ answer: data.choices?.[0]?.message?.content || '' });
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: 'AI request failed' });
  }
});

app.listen(3000, () => console.log('✅ AI server running on http://localhost:3000'));

What this does, line by line:

  • app.post('/ai', …) creates one address your editor can call: http://localhost:3000/ai
  • Froala hands us the user's question as req.body.prompt
  • We forward it to the AI provider with the key attached on the server side.
  • We send back { answer: "…" }

Step 3: Start the server

node server.js

You should see ✅ AI server running on http://localhost:3000. Leave this terminal window open. If you close it, the server stops and the editor will show "request failed."

Step 4: Create the editor page

Create index.html next to server.js:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <!-- Froala's stylesheet -->
  <link href="https://cdn.jsdelivr.net/npm/froala-editor@latest/css/froala_editor.pkgd.min.css" rel="stylesheet">
</head>
<body>
  <!-- The editor will replace this div -->
  <div id="editor"><p>Try selecting this sentence, or open the AI Chat panel.</p></div>

  <!-- Froala's JavaScript (the "pkgd" bundle includes every plugin, AI Assist included) -->
  <script src="https://cdn.jsdelivr.net/npm/froala-editor@latest/js/froala_editor.pkgd.min.js"></script>
  <script>
    new FroalaEditor('#editor', {
      // 1️⃣ REQUIRED: accept the AI Supplemental Terms.
      // Without this, every AI feature stays switched off and no icons appear.
      aiSupplementalTermsAccepted: true,

      // 2️⃣ REQUIRED: tell Froala how to reach an AI.
      // This function runs every time the user sends a message.
      aiAssistRequest: async function (request, signal) {
        // 'request.prompt' = the user's question plus any context they attached
        const response = await fetch('http://localhost:3000/ai', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ prompt: request.prompt }),
          signal // lets Froala cancel this if the user hits Cancel
        });
        const data = await response.json();

        // Return the answer text — Froala shows it in the chat panel
        return data.answer;
      },

      // Put the AI buttons on the toolbar
      toolbarButtons: [
        'bold', 'italic', 'underline', '|',
        'aiChatAssistant', // opens the AI Chat side panel
        'aiAssist', // opens the one-off prompt popup
        'aiShortCuts' // tone & translate dropdown
      ]
    });
  </script>
</body>
</html>

The two lines that matter most are aiSupplementalTermsAccepted and aiAssistRequest. Everything else on this page is optional tuning. Until both of those are set, the AI icons will not appear on the toolbar at all.

Step 5: Test it

  1. Make sure node server.js is still running in your terminal.
  2. Open index.html in your browser (double-clicking the file is fine).
  3. Click the AI Chat icon in the toolbar. The panel slides in from the right.
  4. Type "Write me a haiku about databases" and press Enter.

🎉 If a reply streams back, it’s working.

Connecting to a real AI provider

Froala doesn't ship with any AI built in. It's provider-agnostic, meaning you decide which AI service answers the questions.

aiAssistRequest is the single function that connects Froala to your AI (Groq, Google, Anthropic, OpenAI, your own backend, anything). It's called every time a user sends a message.

aiAssistRequest: async function (request, signal, onChunk) { … }

What Froala hands you (request)

Field What it is
request.prompt The user's question, with any document / selection / file / URL context already folded in as text
request.model The modelName the user picked (only present if you set aiChatModels)
request.webSearchEnabled true if the web-search toggle was on
request.reasoningEnabled true if the reasoning toggle was on
request.files Array of attached files, already read and encoded for you.
request.referenceUrls Array of plain URL strings the user added

The signal argument is an AbortSignal, pass it to fetch so the Cancel button actually stops the request.

The shape of request.files

You never handle a raw file input. Froala reads and encodes every attachment for you, then hands you one of two shapes:

File types Shape Notes
pdf, jpg, jpeg, png, gif, webp { name, mimeType, base64 } Raw bytes, base64-encoded, no resizing or re-encoding — pass straight to your provider's native file/vision input
txt, md { name, mimeType, textContent } Already decoded plain text
docx { name, mimeType, textContent } ⚠️ Mammoth-converted HTML, not stripped plain text. Requires Mammoth.js

By default this same content is also folded into request.prompt as a labelled text block, so simple integrations work with no extra code. If you'd rather map files to your provider's native format (Gemini inlineData, OpenAI input_file, Claude document/image blocks), read request.files yourself and ignore the folded text.

What to return

A plain string works and always will:

return 'The answer text';

To light up the "used web search" badge, return an object instead:

return {
  answer: 'The answer text',
  webSearchUsed: true, // did your provider ACTUALLY search? (see 4.5)
  session_id: 'abc123' // optional, for conversation continuity
};

Streaming (the typing-out effect)

If Froala passes a third argument, onChunk, it wants a streaming answer. Call onChunk(text) each time a piece arrives, then return the full answer at the end exactly as before.

If you ignore the third argument entirely, that's fine, Froala just waits for your function to finish. Streaming is controlled by aiChatStreamResponse (default true).

aiAssistRequest: async function (request, signal, onChunk) {
  const streaming = typeof onChunk === 'function'; // is Froala asking for a stream?
  // …
}

Ready-to-use provider examples

Below are three ready-to-use examples. Swap in your own API key (as an environment variable or from your own backend — never hardcode a real key directly in client-side code that gets committed to a repo, see the note at the end of this guide).

Groq

Groq's API is OpenAI-compatible, so the request/response shapes below are the same ones you'd use for OpenAI itself, just pointed at a different URL.

aiAssistRequest: async function (request, signal, onChunk) {
  const streaming = typeof onChunk === 'function';

  const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${GROQ_API_KEY}` // <- your key here
    },
    body: JSON.stringify({
      model: request.model || 'openai/gpt-oss-120b',
      messages: [{ role: 'user', content: request.prompt }],
      stream: streaming,
      ...(request.webSearchEnabled ? { tools: [{ type: 'browser_search' }] } : {})
    }),
    signal
  });

  if (!streaming) {
    const data = await response.json();
    const message = data?.choices?.[0]?.message;
    // Groq reports built-in-tool use (browser_search) via `executed_tools` on
    // the message — a non-empty array means it actually searched. Never
    // infer this from the `tools` flag you sent; the model can choose not to
    // call it.
    return {
      answer: message?.content?.trim() || '',
      webSearchUsed: !!(message?.executed_tools && message.executed_tools.length)
    };
  }

  // Streaming: Groq sends Server-Sent Events, one JSON chunk per line.
  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = '', answer = '', webSearchUsed = false, done = false;

  while (!done) {
    const result = await reader.read();
    done = result.done;
    buffer += decoder.decode(result.value || new Uint8Array(), { stream: !done });

    const lines = buffer.split('\n');
    buffer = lines.pop(); // the last line might be cut off mid-way — keep it for next time

    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed.startsWith('data:') || trimmed === 'data: [DONE]') continue;

      const choice = JSON.parse(trimmed.slice(5)).choices?.[0];
      const executedTools = choice?.delta?.executed_tools || choice?.message?.executed_tools;
      if (executedTools && executedTools.length) {
        webSearchUsed = true;
      }

      const delta = choice?.delta?.content;
      if (delta) {
        answer += delta;
        onChunk(delta);
      }
    }
  }

  return { answer: answer.trim(), webSearchUsed };
}

Google (Gemini)

aiAssistRequest: async function (request, signal, onChunk) {
  const parts = [{ text: request.prompt }];

  // Attach any files as native Gemini input.
  (request.files || []).forEach(file => {
    if (file.base64) {
      parts.push({ inlineData: { mimeType: file.mimeType, data: file.base64 } }); // PDF / image
    } else {
      parts.push({ text: file.textContent }); // TXT / MD / DOCX
    }
  });

  const body = {
    contents: [{ parts }],
    ...(request.webSearchEnabled ? { tools: [{ google_search: {} }] } : {})
  };

  const model = request.model || 'gemini-2.5-flash';
  const streaming = typeof onChunk === 'function';
  const endpoint = streaming
    ? `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse`
    : `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;

  const response = await fetch(endpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-goog-api-key': GOOGLE_API_KEY }, // <- your key here
    body: JSON.stringify(body),
    signal
  });

  if (!streaming) {
    const data = await response.json();
    const candidate = data?.candidates?.[0];
    // Gemini reports google_search use via groundingMetadata.webSearchQueries
    // on the candidate — non-empty means it actually issued a search.
    const queries = candidate?.groundingMetadata?.webSearchQueries;
    return {
      answer: candidate?.content?.parts?.[0]?.text?.trim() || '',
      webSearchUsed: !!(queries && queries.length)
    };
  }

  // Streaming: Gemini also sends Server-Sent Events, "data: {...}" per line.
  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = '', answer = '', webSearchUsed = false, done = false;

  while (!done) {
    const result = await reader.read();
    done = result.done;
    buffer += decoder.decode(result.value || new Uint8Array(), { stream: !done });

    const lines = buffer.split('\n');
    buffer = lines.pop();

    for (const line of lines) {
      if (!line.startsWith('data:')) continue;
      const candidate = JSON.parse(line.slice(5)).candidates?.[0];
      const queries = candidate?.groundingMetadata?.webSearchQueries;
      if (queries && queries.length) {
        webSearchUsed = true;
      }

      const delta = candidate?.content?.parts?.[0]?.text;
      if (delta) {
        answer += delta;
        onChunk(delta);
      }
    }
  }

  return { answer: answer.trim(), webSearchUsed };
}

Anthropic (Claude)

aiAssistRequest: async function (request, signal, onChunk) {
  const content = [{ type: 'text', text: request.prompt }];

  // Attach any files as native Claude input.
  (request.files || []).forEach(file => {
    if (file.base64 && file.mimeType === 'application/pdf') {
      content.push({ type: 'document', source: { type: 'base64', media_type: file.mimeType, data: file.base64 } });
    } else if (file.base64) {
      content.push({ type: 'image', source: { type: 'base64', media_type: file.mimeType, data: file.base64 } });
    } else {
      content.push({ type: 'text', text: file.textContent });
    }
  });

  const streaming = typeof onChunk === 'function';

  const response = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': ANTHROPIC_API_KEY, // <- your key here
      'anthropic-version': '2023-06-01'
    },
    body: JSON.stringify({
      model: request.model || 'claude-sonnet-5',
      max_tokens: 2048,
      messages: [{ role: 'user', content }],
      stream: streaming,
      ...(request.webSearchEnabled
        ? { tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }] }
        : {})
    }),
    signal
  });

  if (!streaming) {
    const data = await response.json();
    const blocks = data?.content || [];
    // Claude reports web_search tool use as its own content blocks
    // ("server_tool_use" / "web_search_tool_result") alongside the text block.
    return {
      answer: blocks.find(block => block.type === 'text')?.text?.trim() || '',
      webSearchUsed: blocks.some(block => block.type === 'server_tool_use' || block.type === 'web_search_tool_result')
    };
  }

  // Streaming: Claude sends Server-Sent Events with named event types;
  // the text itself lives in "content_block_delta" events, and a search is
  // announced by a "content_block_start" event for one of the block types above.
  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = '', answer = '', webSearchUsed = false, done = false;

  while (!done) {
    const result = await reader.read();
    done = result.done;
    buffer += decoder.decode(result.value || new Uint8Array(), { stream: !done });

    const lines = buffer.split('\n');
    buffer = lines.pop();

    for (const line of lines) {
      if (!line.startsWith('data:')) continue;
      const event = JSON.parse(line.slice(5));

      if (event.type === 'content_block_start') {
        const blockType = event.content_block?.type;
        if (blockType === 'server_tool_use' || blockType === 'web_search_tool_result') {
          webSearchUsed = true;
        }
      }

      const delta = event.type === 'content_block_delta' ? event.delta?.text : null;
      if (delta) {
        answer += delta;
        onChunk(delta);
      }
    }
  }

  return { answer: answer.trim(), webSearchUsed };
}

Optional: smoothing out the typing effect

Chunks don't arrive at a steady pace, sometimes a whole burst lands at once, which looks jumpy. Add a tiny delay before each chunk for a natural typing feel:

function paceForTypingEffect(onChunk, delayMs = 30) {
  return async function (text) {
    await new Promise(resolve => setTimeout(resolve, delayMs));
    onChunk(text);
  };
}

aiAssistRequest: async function (request, signal, onChunk) {
  if (onChunk) onChunk = paceForTypingEffect(onChunk); // then use onChunk as normal
  // …
}

It changes nothing about the answer. If a request is cancelled mid-flight, check signal.aborted before calling the paced onChunk so nothing appears after the user hits Cancel.

If you want the little "used web search" badge to show up on a response, your handler needs to tell Froala when the provider actually searched. Turning the toggle on only asks the model to search if it decides to; it doesn't guarantee it did. Do this by resolving an object instead of a plain string:

aiAssistRequest: async function (request, signal, onChunk) {
  // ...call your provider...
  return {
    answer: theAnswerText,
    webSearchUsed: true // or false — whatever your provider actually told you
  };
}

All above examples already do this. See the comment next to webSearchUsed in each one for how that particular provider reports it. A plain string return still works exactly as before; it's just treated as if webSearchUsed were false, so the badge won't show.

The alternative: aiAssistEndpoint

Don't want to write a handler function at all? Point Froala at your own server URL and it will do the fetching for you:

new FroalaEditor('#editor', {
  aiSupplementalTermsAccepted: true,
  aiAssistEndpoint: 'https://my-server.com/ai',
  aiAssistHeaders: { 'Authorization': 'Bearer my-session-token' },
  aiAssistResponseParserPath: 'choices[0].message.content' // where the answer lives
});

Two things to know:

  • aiAssistDataKeys only renames fields for this path. It has no effect on a custom aiAssistRequest function.
  • On this path, session_id and webSearchUsed are only reported for non-streaming requests. Only a custom aiAssistRequest handler can report them while streaming.

Security

Every example in this guide uses a placeholder (GROQ_API_KEY, GOOGLE_API_KEY, ANTHROPIC_API_KEY) on purpose.

  • Never hardcode a real API key in code that ends up in your browser bundle or in version control. Anyone who views your site's source — or your repo's history — can read it back later, even after you remove it.
  • Keep the key on your own server, as in the Quick Start. Froala supports this directly: aiAssistEndpoint + aiAssistHeaders point AI Chat at your backend, so the key never leaves it.
  • 🔄 If a key is ever exposed, treat it as compromised. Rotate or revoke it with the provider rather than just deleting the line — old copies linger in logs, caches, and git history.

Also worth knowing: every uploaded file is read and encoded client-side, then sent straight to your provider. Nothing round-trips through Froala infrastructure.

Troubleshooting

Symptom Most likely cause
No AI icons on the toolbar at all aiSupplementalTermsAccepted isn't true, or no aiAssistRequest / aiAssistEndpoint is configured. Both are required.
AI Chat icon missing specifically 'aiChatAssistant' isn't in your toolbarButtons array.
Panel opens but looks unstyled The plugin CSS isn't loaded — check ai_assist.min.css.
Everything errors with a network failure Your backend isn't running. Check the terminal where you ran node server.js.
DOCX attachments do nothing Mammoth.js isn't loaded. The file never reaches your handler — see section 3.
A file was rejected It exceeded aiChatMaxFileSizeMB (default 20MB), exceeded aiChatMaxFiles (default 10), or its extension isn't in aiChatSupportedFileTypes.
No model dropdown in the composer aiChatModels is empty — that's the default, and it hides the dropdown by design.
"Used web search" badge never appears Your handler returns a plain string. Return { answer, webSearchUsed } instead — see 4.3.
Answers appear all at once, not streaming Either aiChatStreamResponse is false, or your handler ignores the third onChunk argument.
Requests time out on long answers Raise aiChatRequestTimeoutMs (default 60000) or set it to 0.
Mic button missing aiSpeechToText is false, or no request handler is configured. It needs both.
Mic does nothing when clicked Microphone permission was denied, or the page isn't served over HTTPS (or localhost) — browsers require a secure context for mic access.
Ghost text never appears inlineSuggestions is false, AI Assist isn't active, or the caret isn't at the end of a block — suggestions only fire at block end.
Ghost text appears then vanishes constantly inlineSuggestionsDelay may be too low, cancelling requests on every keystroke. Try 300ms.
Chat panel and Comments panel fight each other Expected — only one right-docked panel shows at a time, by design.
Panel width resets on reload Also expected. Froala stores nothing itself — save it via aiAssist.chatPanelResized and pass it back as aiChatPanelWidth.
applyExternalRewriteDirect returns false The editor was busy, or the document changed while the request was in flight. Re-capture the range and retry.

Do you think we can improve this article? Let us know.