---
title: "Blok Agent API — An In-App Assistant That Edits the Document"
description: "Turn on the built-in Ask AI assistant with your own model, or run your own loop through editor.agent. The agent edits the open document visibly, stoppably and with undo."
source: https://blokeditor.com/docs/agent-api/
lastmod: 2026-10-30
---

Framework JavaScript

Extending & system Agent

On this page agent.tools(options)

# 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, 2026 [Edit this page on GitHub](https://github.com/JackUait/blok/blob/main/docs/src/components/api/api-data.ts)

### 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) });
  }
});
```
