@shashimadushan/docx-editor-agent
v0.2.0
Published
AI agent for @shashimadushan/docx-editor-editor. Tool-calling agent that can read, write, insert, delete, format, and search document content via any LLM (OpenAI, Anthropic, or custom).
Downloads
87
Maintainers
Readme
@shashimadushan/docx-editor-agent
AI agent for @shashimadushan/docx-editor-editor. Tool-calling agent that can read, write, insert, delete, format, and search document content via any LLM.
Install
npm install @shashimadushan/docx-editor-agent @shashimadushan/docx-editor-editor react react-domQuick start (React)
import * as React from 'react';
import { ReactDocxEditor, type DocxEditor } from '@shashimadushan/docx-editor-editor/react';
import '@shashimadushan/docx-editor-editor/style.css';
import { BackendAdapter } from '@shashimadushan/docx-editor-agent';
import { useDocxAgent } from '@shashimadushan/docx-editor-agent/react';
function App() {
const [editor, setEditor] = React.useState<DocxEditor | null>(null);
// Memoize the adapter — useDocxAgent rebuilds its DocxAgent (and resets
// the conversation) whenever the adapter *reference* changes, so passing
// `new BackendAdapter(...)` inline here would reset on every render.
// BackendAdapter talks to YOUR server (see the "BackendAdapter" section
// below) — no provider API key ever reaches the browser.
const adapter = React.useMemo(() => new BackendAdapter({ baseUrl: '/api/agent' }), []);
const { isRunning, log, run, reset, error } = useDocxAgent({ adapter }, editor);
return (
<>
<ReactDocxEditor onReady={setEditor} />
<button onClick={() => run('add heading called Summary')} disabled={isRunning || !editor}>
Add heading
</button>
<pre>{JSON.stringify(log, null, 2)}</pre>
</>
);
}Quick start (headless)
import { DocxEditor } from '@shashimadushan/docx-editor-editor';
import { DocxAgent, OpenAIAdapter } from '@shashimadushan/docx-editor-agent';
import '@shashimadushan/docx-editor-editor/style.css';
const editor = new DocxEditor({ element: document.getElementById('host')! });
const agent = new DocxAgent({
editor,
adapter: new OpenAIAdapter({
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-4o-mini',
}),
});
// Run an autonomous task
const reply = await agent.run(
'Add a heading called "Summary" and a 3-bullet list of key points.',
);
console.log(reply);
// Inspect what happened
console.log(agent.history);LLM adapters
OpenAIAdapter
Works with OpenAI and any OpenAI-compatible provider (Together AI, Groq, Mistral, OpenRouter, vLLM, LM Studio, Ollama with OpenAI compatibility, etc.):
import { OpenAIAdapter } from '@shashimadushan/docx-editor-agent';
new OpenAIAdapter({
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-4o-mini',
// Optional: use any OpenAI-compatible provider
baseURL: 'https://api.groq.com/openai/v1',
});AnthropicAdapter
import { AnthropicAdapter } from '@shashimadushan/docx-editor-agent';
new AnthropicAdapter({
apiKey: process.env.ANTHROPIC_API_KEY!,
model: 'claude-3-5-sonnet-20241022',
});BackendAdapter (recommended for anything shipped to users)
OpenAIAdapter/AnthropicAdapter need a provider API key in the browser — fine for a local prototype, not for production. BackendAdapter instead talks to two endpoints on your own server, which holds the real key and makes the provider call for you:
import { BackendAdapter } from '@shashimadushan/docx-editor-agent';
const adapter = new BackendAdapter({
baseUrl: '/api/agent', // relative or absolute; change anytime via adapter.setBaseUrl(url)
// headers: () => ({ Authorization: `Bearer ${myAppSessionToken}` }), // auth to YOUR api, not the LLM provider
});It POSTs the exact same { messages, tools, temperature, maxTokens } body DocxAgent always builds to:
POST {baseUrl}/chat— one-shot; your server responds with JSON{ message, finishReason, usage? }.POST {baseUrl}/chat/stream— streaming; your server respondstext/event-streamwithdata:lines shaped{ type: 'token'|'thinking', text }and a final{ type: 'done', message, finishReason, usage? }.
The simplest server implementation reuses this same package's OpenAIAdapter/AnthropicAdapter on the server, where holding a secret key is fine. Don't just forward whatever tools/system message the client sent, though — that puts the browser in charge of the agent's capabilities. Use @shashimadushan/docx-editor-agent/server's handleAgentChat/handleAgentChatStream instead: they build the tool schema + system prompt from this package's own registry (server-authoritative), call the adapter, and shape the response BackendAdapter expects — a route is a few lines:
// app/api/agent/chat/route.ts (Node or Edge runtime; any server framework works the same way)
import { handleAgentChat } from '@shashimadushan/docx-editor-agent/server';
import { OpenAIAdapter } from '@shashimadushan/docx-editor-agent';
const adapter = new OpenAIAdapter({ apiKey: process.env.OPENAI_API_KEY!, model: 'gpt-4o-mini' });
export async function POST(req: Request) {
return Response.json(await handleAgentChat(await req.json(), adapter));
}// app/api/agent/chat/stream/route.ts — same idea, streaming
import { handleAgentChatStream, AGENT_SSE_HEADERS } from '@shashimadushan/docx-editor-agent/server';
export async function POST(req: Request) {
return new Response(handleAgentChatStream(await req.json(), adapter), { headers: AGENT_SSE_HEADERS });
}See examples/demo/app/api/agent/chat/route.ts and .../chat/stream/route.ts for the complete reference implementation. handleAgentChat* take an optional third argument, { toolRegistry?, systemPrompt? }, if you need a custom tool set (e.g. legal tools merged in, or some builtins disabled) or a different prompt — pass systemPrompt: null to omit it entirely if you'd rather manage that yourself.
Custom adapter
Implement the LLMAdapter interface:
import type { LLMAdapter, LLMMessage, LLMResponse } from '@shashimadushan/docx-editor-agent';
class MyAdapter implements LLMAdapter {
async complete({ messages, tools }): Promise<LLMResponse> {
// Call your LLM...
return {
message: {
role: 'assistant',
content: 'Done.',
toolCalls: [
{ id: 'call_1', name: 'insert_heading', arguments: { text: 'Hello', level: 2 } },
],
},
finishReason: 'tool_calls',
};
}
}Built-in tools (67)
67 tools across 14 categories, each carrying category + risk
(read / write / destructive) metadata used by the policy gate (see
"Security" below). ⚠ marks a destructive tool. For the full generated
listing, call buildToolCategoryGuide(tools) or read
skill/references/agent-api.md.
| Category | Tools |
|---|---|
| Inspection (read) | get_outline, get_word_count, list_blocks, get_block, get_block_range, find_block_by_text, find_target_block, get_selection, get_document, get_block_count |
| Batch | batch_insert, batch_format, batch_delete ⚠, batch_reorder |
| Insert | insert_paragraph, insert_heading, insert_bullet_list, insert_ordered_list, insert_table, insert_code_block, insert_blockquote, insert_horizontal_rule, append_html |
| Insert media | insert_image, insert_link, insert_page_break |
| Format | set_bold, set_italic, set_underline, set_strikethrough, set_color, set_alignment, set_heading, set_paragraph |
| Style | set_paragraph_style, clear_formatting, set_font_family, set_font_size, set_line_height, set_text_transform |
| Image layout | set_image_alignment, set_image_wrap, set_image_size |
| Table | get_table_info, set_table_cell, add_table_row, delete_table_row ⚠, add_table_column, delete_table_column ⚠ |
| Manipulation | duplicate_block, insert_at_cursor, replace_selection, wrap_selection, set_block_text |
| Cursor-relative | get_cursor_block, insert_relative, delete_current_block ⚠ |
| Structure | delete_block ⚠, replace_block, move_block |
| Search | find_text, replace_text ⚠, clear_document ⚠ |
| Page-level | set_watermark, set_header, set_footer |
| Navigate | scroll_to_block |
Selected tool parameters:
Insert content (9)
| Tool | Description | Parameters |
|---|---|---|
| insert_paragraph | Insert a paragraph | text: string, index?: number, bold?: boolean, italic?: boolean |
| insert_heading | Insert a heading | text: string, level?: 1-6 (default: 2), index?: number |
| insert_bullet_list | Insert a bulleted list | items: string[], index?: number |
| insert_ordered_list | Insert a numbered list | items: string[], index?: number |
| insert_table | Insert a table | rows?: number (default: 3), cols?: number (default: 3), headers?: string[], data?: string[][], index?: number |
| insert_code_block | Insert a code block | code: string, index?: number |
| insert_blockquote | Insert a blockquote | text: string, index?: number |
| insert_horizontal_rule | Insert an <hr> | index?: number |
| append_html | Append arbitrary HTML | html: string |
Insert media (3)
| Tool | Description | Parameters |
|---|---|---|
| insert_image | Insert an image | src: string (URL or data URI), alt?: string, title?: string, index?: number |
| insert_link | Insert a hyperlink | href: string, text: string, index?: number (omit to append a new paragraph) |
| insert_page_break | Insert a page break | index?: number |
Format (8)
| Tool | Description | Parameters |
|---|---|---|
| set_bold | Apply/remove bold | index?: number (omit for all), enabled?: boolean (default: true) |
| set_italic | Apply/remove italic | index?: number, enabled?: boolean |
| set_underline | Apply/remove underline | index?: number, enabled?: boolean |
| set_strikethrough | Apply/remove strikethrough | index?: number, enabled?: boolean |
| set_color | Set text color | color: string (CSS color), index?: number |
| set_alignment | Set text alignment | alignment: 'left' \| 'center' \| 'right' \| 'justify', index?: number |
| set_heading | Convert a block to a heading | index: number, level: 1-6 |
| set_paragraph | Convert a block back to a paragraph | index: number |
Style / font (4)
| Tool | Description | Parameters |
|---|---|---|
| set_paragraph_style | Convert a block to a different style | index: number, style: 'paragraph' \| 'h1'-'h6' \| 'blockquote' \| 'codeBlock' |
| clear_formatting | Remove all marks | index?: number (omit for all) |
| set_font_family | Set font family | fontFamily: string (CSS), index?: number |
| set_font_size | Set font size | fontSize: string (CSS length like "12pt"), index?: number |
Mutate structure (3)
| Tool | Description | Parameters |
|---|---|---|
| delete_block | Delete a block | index: number |
| replace_block | Replace a block with new JSON | index: number, block: object |
| move_block | Move a block from one position to another | from: number, to: number |
Search & clear (3)
| Tool | Description | Parameters |
|---|---|---|
| find_text | Find occurrences of a substring | query: string, caseSensitive?: boolean (default: false) |
| replace_text | Replace occurrences | find: string, replace: string, caseSensitive?: boolean, firstOnly?: boolean |
| clear_document | Empty the document | — |
Navigate (1)
| Tool | Description | Parameters |
|---|---|---|
| scroll_to_block | Scroll editor to a block | index: number |
DocxAgent options
interface DocxAgentOptions {
editor: DocxEditor; // the editor to operate on
adapter: LLMAdapter; // LLM adapter (OpenAI/Anthropic/Backend/custom)
systemPrompt?: string; // override the default system prompt
tools?: ToolRegistry; // custom tool registry (default: all builtins)
disableTools?: string[]; // disable specific builtin tools by name
customTools?: AgentTool[]; // additional tools on top of builtins
temperature?: number; // default: 0.2
maxTokens?: number; // default: 1024
maxRounds?: number; // max tool-call rounds per run() (default: 8)
// Security / tool safety (all optional):
disableToolsByRisk?: ('read' | 'write' | 'destructive')[]; // drop risk tiers from schema + execution
allowedCategories?: ToolCategory[]; // allow-list categories only
onConfirmDestructive?: (info: { toolName: string; args: any }) => boolean | Promise<boolean>;
}Security
The agent applies defense-in-depth without touching the editor's mutation
flow. Full details in skill/references/agent-api.md ("Security model") and
ARCHITECTURE.md.
- Tool risk gating — every tool has a
risk(read/write/destructive). PassonConfirmDestructiveto confirm before destructive tools run, ordisableToolsByRisk/allowedCategoriesto drop them from both the schema and the executable set (e.g. a read-only reviewer agent). - Content sanitization —
append_html/set_header/set_footersanitize HTML (strip scripts, event handlers, unsafe URLs);insert_image/insert_linkreject dangerous URL schemes. Swap in a stricter sanitizer viasetHtmlSanitizer. - Server request bounds —
validateAgentRequest(from/server) clampstemperature/maxTokensand caps payload size. Auth + rate limiting remain the consuming app's responsibility.
Disable specific tools
new DocxAgent({
editor,
adapter,
disableTools: ['clear_document'], // prevent the agent from clearing the doc
});Custom system prompt
new DocxAgent({
editor,
adapter,
systemPrompt: 'You are a strict legal-document editor. Only make changes the user explicitly requests.',
});Custom tools
import type { AgentTool } from '@shashimadushan/docx-editor-agent';
const translateTool: AgentTool = {
name: 'translate_document',
description: 'Translate all text in the document to the target language.',
parameters: {
type: 'object',
properties: {
language: { type: 'string', description: 'Target language, e.g. "Spanish".' },
},
required: ['language'],
},
execute: async (params, ctx) => {
const text = ctx.getText();
const translated = await myTranslateAPI(text, params.language);
// ... replace text in document via ctx.setDoc() ...
return { original: text, translated };
},
};
new DocxAgent({
editor,
adapter,
customTools: [translateTool],
});AgentTool interface
interface AgentTool<TParams = any, TResult = any> {
name: string; // unique tool name
description: string; // the LLM uses this to decide when to call
parameters: JSONSchema; // JSON Schema for params
execute: (params: TParams, ctx: AgentToolContext) => Promise<TResult> | TResult;
}
interface AgentToolContext {
editor: TipTapEditor; // the underlying TipTap editor
doc: any; // current document as JSON
setDoc(json: any): void; // replace the whole document
appendContent(content: string | any): void; // append HTML or JSON node
insertAtCursor(content: string | any): void; // insert at cursor
getText(): string; // plain text of the document
}useDocxAgent React hook
const { agent, isRunning, log, run, reset, error } = useDocxAgent(options, editor);| Return | Type | Description |
|---|---|---|
| agent | DocxAgent \| null | The agent instance (null until editor is ready) |
| isRunning | boolean | True while the agent is processing |
| log | LogEntry[] | Conversation log |
| run(prompt) | Promise<void> | Send a user prompt to the agent |
| reset() | void | Clear conversation history |
| error | string \| null | Last error message |
Memoize options.adapter. The hook rebuilds its internal DocxAgent
(and clears log/error) whenever editor or options.adapter changes
identity — that's what makes swapping providers at runtime (e.g. a dropdown
that switches between BackendAdapter/OpenAIAdapter/AnthropicAdapter, or
picks up a newly-entered API key or a changed baseUrl) actually take effect
instead of silently continuing to run whatever adapter was active on the
first render. The tradeoff: passing a fresh adapter instance on every render
(adapter: new BackendAdapter({ baseUrl }) inline in JSX) resets the
conversation on every render too. Wrap it in React.useMemo(() => new
BackendAdapter({ baseUrl }), [baseUrl]), keyed on whatever actually
determines the adapter (provider kind, API key/baseUrl, model), same as
examples/demo/app/page.tsx does.
LogEntry types
type LogEntry =
| { id: string; type: 'user'; text: string; ts: number }
| { id: string; type: 'assistant'; text: string; ts: number }
| { id: string; type: 'thinking'; text: string; ts: number; streaming: boolean }
| {
id: string; // the tool call's id — the matching result updates this same entry
type: 'tool';
name: string;
args: any;
status: 'running' | 'done' | 'error';
result?: any;
summary?: string;
ts: number;
};A tool_call and its tool_result are merged into a single 'tool' entry
(matched by call id) rather than two separate log entries — the entry starts
as status: 'running' and updates in place once the result comes back. The
'thinking' entry streams in incrementally when the adapter supports it
(currently AnthropicAdapter with thinking: true); streaming is true
until that round's reasoning is complete.
How the agent loop works
When you call agent.run(prompt):
- Send
{ system prompt, conversation history, tool definitions }to the LLM - If the LLM returns a tool call, execute it and append the result to history
- Repeat (up to
maxRounds) until the LLM returns a plain text response with no tool calls - Return the final text response
User: "Add a heading called Summary"
↓
LLM: tool_call(insert_heading, { text: "Summary", level: 2 })
↓
Agent executes insert_heading → returns { insertedAt: 3 }
↓
LLM: "Done — I added a 'Summary' heading to the document."
↓
return "Done — I added a 'Summary' heading to the document."Example: full agent UI
function AgentPanel({ editor }: { editor: DocxEditor | null }) {
const adapter = React.useMemo(() => new BackendAdapter({ baseUrl: '/api/agent' }), []); // see note above — memoize this
const { isRunning, log, run, reset, error } = useDocxAgent({ adapter }, editor);
const [prompt, setPrompt] = React.useState('');
const send = async () => {
if (!prompt.trim()) return;
await run(prompt);
setPrompt('');
};
return (
<div style={{ width: 340, display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, overflow: 'auto' }}>
{log.map((entry, i) => (
<div key={i}>
{entry.type === 'user' && <p><b>You:</b> {entry.text}</p>}
{entry.type === 'thinking' && <p><i>Thinking: {entry.text}</i></p>}
{entry.type === 'assistant' && <p><b>Agent:</b> {entry.text}</p>}
{entry.type === 'tool' && (
<pre>→ {entry.name} ({entry.status}): {entry.summary ?? JSON.stringify(entry.args)}</pre>
)}
</div>
))}
</div>
<input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder="Ask the agent…"
disabled={isRunning}
/>
<button onClick={send} disabled={isRunning}>Send</button>
<button onClick={reset}>Reset</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}License
MIT
