We built a custom AI-powered rich text editor as a Retool custom component for a client's internal support tool and walked through it live at Retool's Build Together session this week. The component gives support operators block-level AI actions: rewrite a paragraph, summarize a section, draft a reply from ticket context without leaving the tool.
This post covers why we built it as a custom component, how the architecture works, and how to wire it up in your own Retool app.
Repo: github.com/StackdropCO/retool-ai-editor
Why can't you use a Retool text area with AI actions?
Two reasons: granularity and structured output.
Retool's stock text area gives you a string. AI acting on a string means acting on the whole thing: if an operator wants to rewrite one paragraph out of five, the text area has no concept of that block. You'd need to write your own selection logic, parse the string, find the boundaries, and reconstruct the document after the transformation. That logic lives outside Retool's low-code surface entirely.
The second problem is downstream. Once the operator is done editing, you need to send structured data to queries, tables, and workflow nodes. Parsing a markdown or HTML blob at that stage is unnecessary work.
The combination of block-level targeting and structured JSON output pointed to editor.js.
What does editor.js give you that a text area doesn't?
Editor.js is a block-style rich text editor, think Notion's content model. Every paragraph, heading, and list item is its own independent JSON object. The output isn't a string, it's a structured document:
{
"blocks": [],
"time": 0,
"version": ""
}
You can see this directly in Retool's inspector panel when the component is selected, the editorData output updates in real time as the operator types, and you can bind it to any downstream component:
{{ AIEditor1.editorData.blocks }}
Each block has its own type, content, and identity. That means you can target a specific block with an AI transformation without touching anything else in the document.
Editor.js also runs as real React inside Retool's custom component environment so no sandboxing, no allow list. It installs from npm without modification and supports hooks, refs, effects, CSS modules, and TypeScript.
How does the AI wiring work inside a Retool custom component?
The component exposes two events to Retool: aiTransformRequest and editorChange.
When an operator triggers an AI action, the component sets a group of state properties first, then fires aiTransformRequest. This is a standard Retool custom component pattern: events carry no payload, so state is set before the event fires, and the connected query reads from state:
// Component sets state, then fires the event
setTransformBlockContent(content)
setTransformType(type)
setTransformBlockId(blockId)
setTransformScope(scope)
aiTransformRequest()
// In Retool, the query reads:
{{ AIEditor1.transformBlockContent }}
{{ AIEditor1.transformType }}
Retool runs the connected ai_transform JavaScript query, which calls your LLM endpoint, handles the response, and pushes the result back into the component:
aiEditor1.setValue('transformResponse', raw.trim() + salt)
The salt is String.fromCharCode(8203), a zero-width space that forces Retool to detect a value change even when the AI returns identical text twice in a row.
The prompt, the model, and the context all live in the Retool query, not inside the component. That means anyone configuring the app can change how the AI behaves without touching component code.
The full data flow in the query graph: aiEditor1 β ai_transform β anthropic_api.
What AI actions does the component support?
Three actions, and they work differently depending on how they're triggered.
Rewrite and Summarize: block tune actions
Every block gets an AI action menu in its settings panel (the three-dot toolbar on hover). Rewrite and Summarize live here. When triggered, the component fires aiTransformRequest with transformScope: 'block' and the block's text content in transformBlockContent. The LLM response replaces that specific block in place via editor.blocks.update(id, data) no index math, no re-inserts.
Draft reply: slash-menu action
Typing / opens the slash menu with a Draft reply entry. Selecting it inserts a dimmed, pulsing placeholder block and fires aiTransformRequest with transformScope: 'insert' and transformType: 'draft'. For this action, transformBlockContent is empty, the draft is grounded entirely from app state you pass to the query: ticket context, previous messages, CRM data, whatever's available. The component splits the response on blank lines and replaces the placeholder with one paragraph block per chunk.
What does the file structure look like?
src/
components/
AIEditor/
index.tsx # React component β all Retool state/event wiring
AITransformTune.ts # Block Tune: per-block Rewrite and Summarize actions
AIAction.ts # Block Tool: slash-menu Draft reply action
AIEditor.module.css # Container styles
index.tsx # Barrel export
The Retool-specific surface is contained in index.tsx. To use this outside Retool, swap the Retool state hooks for useState and component props, everything else is pure editor.js and React.
How do you add this component to a Retool app?
1. Clone and install
git clone https://github.com/StackdropCO/retool-ai-editor ai-editor-retool
cd ai-editor-retool
npm install
npx retool-ccl login
npx retool-ccl dev
Dev mode syncs your local changes to Retool on every save. Drag the AIEditor component onto any canvas.
2. Create your transform query
Create a JavaScript query that calls your LLM endpoint (Retool AI, OpenAI, Anthropic, or any other). Switch on {{ AIEditor1.transformScope }} to handle block transforms vs. draft inserts, then on {{ AIEditor1.transformType }} for the specific action. Use {{ AIEditor1.transformBlockContent }} as user content for block-scope transforms.
The query runs in the full Retool app scope, so you can reference any query or component directly in the prompt:
{{ someTable.selectedRow }}
{{ ticketQuery.data }}
3. Add the event handler
On the AIEditor1 component:
-
Event:
aiTransformRequest - Action: Trigger your transform query
-
Then: Set
AIEditor1.transformResponseto{{ yourTransformQuery.data }}
The component handles the rest, routing the response to the right block, replacing the placeholder, or updating in place.
4. Deploy
npx retool-ccl deploy
This pushes an immutable version to your Retool instance. Anyone in the org can drag it onto a canvas like any stock component.
Component properties reference
Output: component writes, your app reads
| Property | Type | Description |
|---|---|---|
editorData |
object | Full editor.js JSON, updated on every change (debounced 400ms) |
transformBlockContent |
string | Text of the block being transformed (empty for insert scope) |
transformType |
string |
summarize, rewrite, or draft
|
transformBlockId |
string | editor.js id of the block being transformed or replaced |
transformScope |
string |
block (tune-menu) or insert (slash-menu Draft reply) |
Input: your app writes, component reads
| Property | Type | Description |
|---|---|---|
transformResponse |
string | LLM response. Bind to your query result. |
readOnly |
boolean | Toggle read-only mode from your app. |
What are the limits of this component?
It starts to feel heavy past around 50 blocks, fine for replies, drafts, and short-form content, but not the right fit for long documents or articles.
No collaborative editing. This is an editor.js limitation. The library doesn't support it natively, and neither does this component.
No streaming on AI responses. The editor waits for the full response before rendering. It shows a "Drafting reply..." loading state while the query runs.
What's next?
We're working on a Bynder asset management integration as the next component. Same approach: encapsulate a third-party library's API into a custom component, expose enough surface for end users to configure without touching code.
If you have questions on the build or want to adapt it for a different use case, drop them in the comments.
Repo: github.com/StackdropCO/retool-ai-editor
Built by John Miniadis at Stackdrop, a Retool-certified agency building governed internal tools for mid-market and enterprise clients across EMEA.
FAQ
Can I use this editor.js component outside of Retool?
Yes. The Retool-specific wiring is isolated to one file (index.tsx). Replace the Retool state hooks with useState and component props, and you have a standalone React component with no Retool dependency.
Does this work with any LLM, or only Anthropic?
Any LLM endpoint works. The component fires a Retool event, and your query handles the API call. You can use Retool AI, OpenAI, Anthropic, or any custom endpoint. The response shape handling in the query supports multiple formats (data?.content?.[0]?.text, data?.completion, data?.answer, data?.text).
How does the component pass block content to the Retool query?
Retool custom component events carry no payload, so the component sets state properties before firing the event. Your query reads {{ AIEditor1.transformBlockContent }} for the block text, {{ AIEditor1.transformType }} for the action, and {{ AIEditor1.transformScope }} to distinguish between block transforms and slash-menu inserts.
What happens if the AI returns the same text twice in a row?
The component appends a zero-width space (String.fromCharCode(8203)) to every response before writing it back. This forces Retool to detect a value change and trigger the update even when the text is identical.
When should I use a custom component instead of a stock Retool text area?
When you need structured output (JSON per block rather than a string), block-level event handling, or UI elements that stock inputs don't support, custom menus, inline toolbars, placeholder animations, and slash commands. If a string output and simple AI actions on the whole field are sufficient, the stock text area is the simpler choice.
Top comments (0)