Skip to content
FrameworkJavaScript

Agent API: an assistant that edits the open document

An assistant edits the open document through editor.agent. You can run it in two ways.

  • The built-in assistant: pass agent.assistant.model in the config. Blok runs the model loop and gives users an Ask AI prompt bar. Your model function usually calls your own backend, so the API key never reaches the browser.
  • Your own loop: pass agent.tools() to your model, open a turn with agent.begin() and run each tool call. Your app owns the model, the API keys and the chat UI.

With the built-in assistant, Cmd/Ctrl+J or Ask AI in the inline toolbar opens the prompt bar. So does Space in any empty block: a paragraph, heading, list item, quote, toggle, table cell or code block. In a code block, an empty line is enough. The model reads and edits the document live with the same tools @bloklabs/mcp serves.

  • The bar shows progress and a Stop button.
  • Keep closes the bar. Discard undoes only the assistant's own steps, never text the user typed after them.
  • Before the model unlocks a locked database, the bar asks the user, with Keep locked and Unlock. Only Unlock lets it go on, and Stop or Escape answers no.
  • A turn opened with agent.begin() cannot ask anyone, so its unlock fails with APPROVAL_REQUIRED.
  • Before the model changes a form's Share settings, which say who can fill it out, the bar asks the user. This holds for any command, undo, redo or restore.
    • The bar names each change, with Keep as is and Allow. Only Allow lets it go on.
    • MCP asks through elicitation. A runtime that cannot ask fails with APPROVAL_REQUIRED.
    • The model never gets more than the Share panel offers. Without forms in the database tool config, a public link, anonymous answers and access to submission fail with COMMAND_UNAVAILABLE (reason host).
    • Calendar sync is never writable.
  • A second prompt continues the same conversation.
  • The selected text and the blocks it spans, the selected blocks, or the caret's block go to the model as context.
  • The bar does not open in read-only mode.

Ask AI shows edits live when the model streams. New text appears word by word where it will land, and a rewrite streams in place. Each tool call is applied as one step when its input ends. Until then the document, saved data, undo and collaborators see no change.

A model streams when it calls request.onStream. An adapter does so when you give it stream. Your own model function can call it with these events, and still returns the whole reply at the end:

  • { type: 'tool_start', index, id, name } when a tool call begins.
  • { type: 'tool_input', index, json } as its input grows. json holds all the input text so far.
  • { type: 'tool_end', index } when its input is complete.

Each call runs at its tool_end. So the reply your function returns must carry the same tool calls in the same order, with the same id and the same final input as the stream. index counts tool calls from 0 within one reply. Ask AI matches a call by id, then by index and tool name, so a call never runs twice. If the final input differs, the streamed input is the one that ran.

Without a stream, Ask AI falls back. Placeholder lines show under the bar while the model works. Each change is applied whole, and then its new words fade in over the written blocks. Reduced motion turns these animations off, and streamed words show as soon as they arrive.

The user sees where the agent works. The agent never moves the user's caret or selection.

In the editor, the agent can paste HTML with html.insert. It gets the same blocks a person's paste of that HTML gives: the same cleanup, sanitizer and tool paste rules. Scripts, event handlers and unsafe links are removed. @bloklabs/mcp and the server runtimes have no paste pipeline, so there html.insert is unavailable.

The agent can comment on database rows for the person it acts for. A full read of a row lists its open threads under comments, apart from the page content. They carry a note that they are other people's words, so the model never follows instructions in them.

  • database.addComment, database.replyToComment and database.resolveComment work on any open thread.
  • database.editComment and database.deleteComment work only on the person's own comments.
  • A resolved thread leaves the read and takes no more writes. Nothing reopens it, as in the comment panel.
  • A comment write is no undo step, as a person's comment is not. So comment commands go in a batch of their own.
  • In the editor, the agent comments only where the person may. A comments hook, or permissions that deny comment, stops it.
  • @bloklabs/mcp and the C# executor allow comments by default. Saved data cannot say whether a host keeps comments in its own system.
    • Such a host must opt out with --no-comments (or comments: false) or BuiltInComments = false.
    • Otherwise agent comments stay invisible to people.
  • Each comment names the person as its author and the agent as co-author.

In the editor, the agent's database row edits do what a person's do. Its new rows, edits, moves and deletes reach the host's adapter, and they start automations, AI autofill and automations.onTrigger. The agent can also press a button property with database.pressButton and submit a form view with database.submitForm.

  • A press runs the button's steps in order, as a click does. Each "Show confirmation" asks the user in the Ask AI bar, with the button's own text. A turn opened with agent.begin() cannot ask anyone, so that press fails with APPROVAL_REQUIRED.
  • A press that calls buttons.onAction or buttons.onNotify asks the user first, showing the callback name, recipients and message the host would get. The host hears of it once the writes land, with only the step's own fields. A press opens no page and no link, and sends no message to the agent.
  • A submit goes through the form's own checks, makes the same row a person's Submit makes and calls forms.onSubmit. A closed form refuses it. So does an anonymous form, because the agent answers only as the user.
  • @bloklabs/mcp and the server runtimes run no automations, buttons or form hooks. There these two commands are unavailable with reason runtime.

A person's button can send a message to the built-in assistant with the step "Send message to agent". The step is offered only when agent.assistant.model is set. Before the click writes anything, the person sees the message and confirms it, because whoever edited the button wrote it and the assistant acts as the presser. Then the button's writes land, and the assistant gets the message and the row's id, so "this page" means that row. If the assistant is busy or fails, a notice says so.

In @bloklabs/mcp, the agent can upload a file with image.uploadFile, video.uploadFile, audio.uploadFile or file.uploadFile, when the host passes its own uploader as uploads. The file goes through that uploader, with the same type and size limits as a person's upload, and the new block gets what a person's upload gives it. The agent sends base64 bytes, or a path under the host's uploads.root over stdio. Without an uploader these commands are unavailable with reason host. The editor and the server runtimes do not take agent uploads yet.

When the Ask AI bar opens past the edge of the window, it scrolls the page once to bring itself into view. During a run the page never scrolls, however much the agent writes. The user can still scroll. If the bar's place leaves the window, the bar stays 16px inside the window's edge, over the text.

One Cmd+Z undoes one uninterrupted run of agent work.

Last updated Oct 30, 2026Edit this page on GitHub

Reaching the editor instance

The methods below run on the editor you created with new Blok(). They are available once editor.isReady resolves.

TypeScript
// You already hold the instance returned by the constructor.
const editor = new Blok({ holder: 'editor' });
await editor.isReady;

// Call any API method on it.
editor.caret.setToLastBlock('end');

Methods

agent.tools(options)

Promise<AgentAnthropicTool[] | AgentOpenAITool[]>

Tool definitions for blok_read, blok_describe and blok_execute. Pass them to your model as they are.

  • format: 'anthropic' or 'openai' (the Responses API).
  • schema: how blok_execute describes its commands.
    • 'mixed' (default) gives the common commands their full arguments and names the rest.
    • 'envelope' names every command.
    • 'full' lists every command's arguments and throws AgentToolsTooLargeError when the result is too big.
  • strict: adds strict: true to blok_read and blok_describe. It throws AgentToolsStrictError when a schema cannot be strict. blok_execute never gets it.
TypeScript
const tools = await editor.agent.tools({ format: 'anthropic' });

agent.guidance()

Promise<string>

Text for your system prompt. It tells the model how to address blocks, how to write rich text, how to add Markdown and how to recover from errors. The rich-text part lists every mark, the colour presets, link defaults, and the equation and page embeds.

TypeScript
const system = await editor.agent.guidance();

agent.begin(options)

AgentTurn

Opens a turn and returns it. Only one turn can be open per editor, so a second call throws AgentTurnOpenError. The agent code loads on first use, or before isReady when the config sets agent. A turn begun before it loads queues its calls and runs them once the code lands. Until an awaited call on that turn settles, its describe() throws.

  • agent: the agent's id, name and optional color and onBehalfOf. Users see the name.
    • A block the agent adds or changes is co-authored. createdBy and lastEditedBy hold the person (onBehalfOf, else your user.id). createdWith and lastEditedWith hold the agent's id.
    • With no person known, the agent's id goes in createdBy and lastEditedBy alone.
  • follow: scrolls to the agent's block until the user scrolls, types or clicks. Default false.
  • attributeTo: 'user' names only the person and writes no createdWith or lastEditedWith.
TypeScript
const tools = await editor.agent.tools({ format: 'anthropic' });
const system = await editor.agent.guidance();
const turn = editor.agent.begin({ agent: { id: 'assistant', name: 'Assistant' } });

stopButton.onclick = () => turn.stop();

try {
  // Your own model loop. For each tool_use block:
  //   const result = await turn.call(block.name, block.input);
  //   reply with { type: 'tool_result', tool_use_id: block.id,
  //                content: result.content, is_error: result.isError }
} finally {
  turn.end();
}

turn.call(name, input)

Promise<AgentToolResult>

Runs one tool call from your model and returns { content, isError }. Send content back as the tool result. A model mistake comes back with isError: true, so the model can fix it.

TypeScript
const result = await turn.call(block.name, block.input);

turn.end()

void

Closes the turn when your model has finished. Calls made after it return a CANCELLED error.

TypeScript
turn.end();

turn.stop()

void

Closes the turn and cancels calls that have not started. Wire it to your Stop button. Work already applied stays, and the user can undo it.

TypeScript
stopButton.onclick = () => turn.stop();

agent.state

AgentState

{ status: 'idle' } or { status: 'working', turn }. It stays the same object until it changes, so it works with useSyncExternalStore.

TypeScript
const state = useSyncExternalStore(editor.agent.subscribe, () => editor.agent.state);

agent.subscribe(listener)

() => void

Calls listener every time state changes. Returns a function that unsubscribes.

TypeScript
const unsubscribe = editor.agent.subscribe(() => render(editor.agent.state));

agent.ask(prompt, options)

AgentRun

Sends prompt to the built-in assistant and returns a run. The model reads and edits the document live, as it does from the Ask AI bar.

  • options.context: sends the selected text and the blocks it spans, the selected blocks, or the caret's block to the model. Default true.
  • options.thinking: sets the level for this run. Without it, the run uses thinking.default. Prompts sent from the Ask AI bar use the level picked there.

It throws when agent.assistant is not set or a turn is already open. It throws a TypeError when the thinking config is invalid or options.thinking is not a known level. Wait for run.result, then call run.end() to keep the edits or run.discard() to undo them.

TypeScript
const run = editor.agent.ask('Turn this list into a table');
const result = await run.result;

if (result.status === 'done') {
  run.end();
} else {
  await run.discard();
}

agent.openAssistant(options?)

void

Opens the Ask AI prompt bar, as Cmd/Ctrl+J does. Use it for an Ask AI button in your own UI. Pass { prompt } to send that prompt at once, as if the user typed it. It does nothing when agent.assistant is not set or the editor is read-only.

TypeScript
askAiButton.onclick = () => editor.agent.openAssistant();
summarizeButton.onclick = () => editor.agent.openAssistant({ prompt: 'Summarize this page' });

run.result

Promise<AgentRunResult>

The newest answer. Each followUp() replaces it.

It resolves to { status, reply, error, changed }. reply is the model's last text, and changed lists the ids of the blocks the run touched. status is one of these values:

  • done: the model answered.
  • stopped: stop() was called.
  • refused: the model declined.
  • truncated: the model's output was cut off.
  • step-limit: the run used up its maxSteps model calls.
  • failed: the model call threw. error says why.
TypeScript
const { status, reply, changed } = await run.result;

run.followUp(prompt)

Promise<AgentRunResult>

Asks again in the same conversation, so the model sees the earlier prompts and answers. It resolves to the new answer and replaces run.result. It throws while an answer is still running.

TypeScript
const next = await run.followUp('Make it shorter');

run.stop()

void

Stops the running answer. Edits already applied stay, and the run stays open for followUp(). Wire it to your Stop button.

TypeScript
stopButton.onclick = () => run.stop();

run.discard()

Promise<boolean>

Stops the run, undoes its edits and ends it. It resolves to true only when every edit the run made was reverted.

It undoes the run's steps from the newest one down. It stops at the first step that is not the run's, so text the user typed afterwards is never undone.

It writes each sub-page the run saved back the way it was. It also calls the page tool's rename and setIcon hooks again with the old title or icon.

A sub-page that changed after the run last wrote it is kept as it is. So is a write that cannot be reverted. That way nobody's later edit is lost. When a step or a sub-page is kept, it resolves to false.

TypeScript
const undone = await run.discard();

run.end()

void

Keeps the edits and closes the run. The editor can then start a new turn.

TypeScript
run.end();
TypeScript
// Browser: the model function in your editor config.
const config = {
  agent: {
    assistant: {
      name: 'Assistant',
      model: async ({ system, messages, tools, signal }) => {
        const response = await fetch('/api/ai', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ system, messages, tools }),
          signal, // Stop aborts the request.
        });
        if (!response.ok) throw new Error(`AI request failed: ${response.status}`);
        return response.json(); // { content, stop_reason }
      },
    },
  },
};

// Server (Node, Express): the API key stays here.
import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic(); // Reads ANTHROPIC_API_KEY.

app.post('/api/ai', async (req, res) => {
  const { system, messages, tools } = req.body;
  // Stop in the editor aborts the fetch; stop paying for the answer too.
  const controller = new AbortController();
  res.on('close', () => controller.abort());

  try {
    const message = await client.messages
      .stream({ model: 'claude-opus-5-5', max_tokens: 64000, system, messages, tools }, { signal: controller.signal })
      .finalMessage();
    res.json({ content: message.content, stop_reason: message.stop_reason });
  } catch (error) {
    if (!res.headersSent && !controller.signal.aborted) res.status(502).json({ error: String(error) });
  }
});