Workspace: find, read, reference and embed Blocks across Documents
A Block address is the pair of a Document ID and a block ID. Block IDs are unique per Document only, so the block ID alone names a Block inside its own Document, and the address names it anywhere in the workspace. The workspace layer builds on that pair: a Block index holds one row per Block of every Document, and everything that crosses a Document boundary asks the index instead of opening the other Document.
| Piece | What it does | Port (capability) |
|---|---|---|
| Block index | one row per Block of every Document; answers get, byDocument, documentIds, tags, search and edges | index |
| Block route | summary or content of one Block by address | index |
| Workspace search | Blocks of the Documents the user may read whose title or text match | index |
| Foreign references | [@<document>#<block>] chips and Derived blocks whose original is in another Document | index |
Document routes and DocumentMutator | list, create, move, remove and edit Documents from the server | documents |
| Tool providers | read-only tools of the document chat; find_blocks and read_block | index (for the two tools) |
| Change set | edits the chat proposes for other Documents, decided per Document | changeSets |
blockEmbed | a Block of any Document shown read-only inside another | index |
Each piece is offered only when its port is present: a missing port turns its routes into 501, its panel off and its tool answer into a sentence for the model. The split between what the library owns and what a host chooses is ADR-0003 (docs/adr/0003-one-host-now-apps-ejectable-later.md).
The Block index
BlockIndex (EditorPorts.index, @skaile/forge-editor/server) is a projection of the Documents, not a second store. The documents port stays the truth, and the index can always be rebuilt from listContents. A row (BlockRow, @skaile/forge-editor/core) holds the Block address, the Block type (type; the node type for a Block that is not a Typed block), title, properties, tags, references, derivedFrom, two content hashes (hash and sourceHash) and the plain text. Every node that carries a block ID has a row; a node without an ID has none.
projectDocument(content, documentId, options?) computes the rows of one Document. It is pure and framework-free, so the same content always gives the same rows:
import type { JSONContent } from '@tiptap/core'
import { type BlockRow, projectDocument } from '@skaile/forge-editor/core'
// The Block index is a projection: the rows of a Document's content, one per node that carries a block ID.
// `projectDocument` is pure, so the same content always gives the same rows.
export function referencesOf(content: JSONContent, documentId: string): BlockRow['references'] {
return projectDocument(content, documentId).flatMap(row => row.references)
}
// Every row holds its address, the Block type, the title, the plain text, the tags and the content hashes.
export function titlesOf(content: JSONContent, documentId: string): string[] {
return projectDocument(content, documentId)
.filter(row => row.title !== '')
.map(({ blockId, type, title }) => `${documentId}#${blockId} (${type}): ${title}`)
}
The editor server keeps the index current. installEditorServer (and provideEditorServer for a Nuxt host) wraps the ports with withBlockIndex: the index is built from listContents at start, with the live content of an open Document first, and every write through the documents port (storeContent, putDocument, deleteDocument) re-projects the Document it touched. A failure of that maintenance is logged and never fails the write: the documents port is the truth, and the next write of the Document re-projects it. A host route that stores content itself calls reindexDocument(id) of the EditorServer handle afterwards. Reads wait for the first build.
The workspace tag list (GET /api/editor/tags, the # popup) is read from the Block index, so it reads no stored Document, with one exception: a Document that is currently open in the collaboration server is read from its live Y.Doc (listWorkspaceTags in server/handlers/editor.ts). A tag added in one open Document then shows in another before the debounced store. The index adds to that, so a tag removed in an open Document stays listed until the store re-projects the Document. A host without the index port gets the tags from the stored content of documents (listCurrentContents) instead, again with the live Y.Doc for open Documents.
Add the port next to the ones a host already has. createWorkspaceTools() registers the chat's two read-only tools, and readableDocuments on identity is optional (see Workspace search):
// server/plugins/editor-adapter.ts
import type { JSONContent } from '@tiptap/core'
import { createMemoryBlockIndex } from '@skaile/forge-editor/adapters/block-index'
import { createWorkspaceTools } from '@skaile/forge-editor/server'
import { provideEditorServer } from '@skaile/forge-editor/server/nitro'
export default defineNitroPlugin(() => {
const contents = new Map<string, JSONContent>()
provideEditorServer({
ports: {
identity: {
authenticate: async () => ({ id: 'demo', username: 'demo' }),
// Optional: the Documents a user may read, so a search never has to ask `authorize` once per Document.
readableDocuments: async () => 'all',
},
documents: {
loadContent: async id => contents.get(id) ?? null,
storeContent: async (id, content) => { contents.set(id, content) },
listContents: async () => [...contents].map(([id, content]) => ({ id, content })),
},
// The `index` capability: a projection of `documents`, rebuilt on start and kept current by the editor server.
index: createMemoryBlockIndex(),
},
// The read-only tools of the document chat: `find_blocks` and `read_block`.
tools: createWorkspaceTools(),
})
})
// Nuxt auto-imports `defineNitroPlugin` in `server/plugins`; this declaration lets the file typecheck on its own.
declare function defineNitroPlugin(plugin: () => void): unknown
Adapters
| Entry | Factory | Use |
|---|---|---|
@skaile/forge-editor/adapters/block-index | createMemoryBlockIndex() | rows in memory by Document, then by block ID; gone with the process, so the server rebuilds them on start |
@skaile/forge-editor/adapters/block-index/drizzle | createDrizzleBlockIndex({ db, tables: blockIndexTables() }), bootstrapBlockIndexTables(db) | the index in Postgres or PGlite through drizzle (drizzle-orm, optional peer) |
The drizzle adapter keeps two tables. editor_block_index (document_id, block_id, seq, row, tags, title_words, text_words, text_length and two generated tsvector columns with GIN indexes) holds the rows; editor_block_edges (from_document_id, from_block_id, seq, to_document_id, to_block_id, tag, kind, origin, with an index on the target) holds their Edges, written in the same transaction as the rows. The whole BlockRow sits in the row JSON column, so a later release can add row fields without a schema change. blockIndexTables() returns both tables ({ blocks, edges }) for the host's drizzle schema, so drizzle-kit migrates them; bootstrapBlockIndexTables(db) creates the tables and their indexes with CREATE ... IF NOT EXISTS for a host without migrations. search runs in the database and returns the same hits in the same order as the memory adapter: every query word must match the title or the text as a whole word or as the start of one, and a word in the title outranks any match in the text only. The adapters share one test suite (test/support/block-index-contract.ts), so a third adapter can be held to the same contract.
The reference app chooses its adapter with NUXT_BLOCK_INDEX (memory, the default, or pglite: Postgres in the process, in memory, rebuilt on every start). This is the PGlite start of server/utils/block-index.ts; a host with a Postgres server passes its own drizzle database to createDrizzleBlockIndex instead:
byDocument: async documentId => (await started()).byDocument(documentId),
documentIds: async () => (await started()).documentIds(),
tags: async () => (await started()).tags(),
search: async (query, scope, options) => (await started()).search(query, scope, options),
edges: async (address, direction, scope) => (await started()).edges(address, direction, scope),
query: async (query, scope, env, definitions) => (await started()).query(query, scope, env, definitions),
...(withVectors && {
nearest: async (vector, scope, options) => (await started()).nearest!(vector, scope, options),
related: async (address, scope, options) => (await started()).related!(address, scope, options),
}),
}
The Block route
GET /api/editor/blocks/:documentId/:blockId answers the summary of one Block: title, type, hash, sourceHash and gone. sourceHash is what the stale badge of a Derived block with an original in another Document compares against the inputHash its Derivation recorded. The server answers 404 for an unknown Block or Document (the HTTP client turns that into gone: true) and 403 for a Document the user may not read. ?content=block adds the Block's own nodes as TipTap JSON (BlockSummary.content), ?content=children the Blocks nested in it; any other value is 400. The content is read from the Document's current content, unsaved edits of an open Document included. In the client the route is backend.index.blockGet(address, signal?, content?); foreign references and Embeds read through it.
Workspace search
GET /api/editor/search?q= answers BlockSearchHit[] (documentId, blockId, title, type), best first, 20 at most. A query is words: a Block matches when every word occurs in its title or text, as a whole word or as the start of one, ignoring case and diacritics; a Block with a word in its title ranks above one that has it only in its text. A query without a word answers [].
Search only ever sees Documents the user may read. The route asks readableDocumentsOf(ports, user) for the scope (DocumentScope: 'all' or a list of Document IDs) and hands it to BlockIndex.search, so a Block of any other Document is never a candidate. There is one read rule for the whole workspace (server/readable-documents.ts: documentAccess, mayReadDocument, mayEditDocument, readableAmong), and the block route, the document GET, the tree, the ops route, the search and the chat's tools all ask it: identity.readableDocuments(user) decides when the host has it, else authorize(user, id) does (null = not readable), and with neither every authenticated user may read every Document. A Document that readableDocuments names but authorize refuses is read-only. Without readableDocuments the scope is 'all' when there is no authorize, else every Document authorize does not refuse; the candidates come from documents.listDocumentIds, else index.documentIds() (the Documents that have Blocks, the only ones a search could find), else the IDs of listContents. A host with many Documents implements readableDocuments, because the derived scope costs one authorize call per Document and request.
The search panel is a framework-free model (createSearchModel, @skaile/forge-editor/search) behind a SearchPanel in each framework (@skaile/forge-editor/search/vue, @skaile/forge-editor/search/react). The panel never opens a hit itself: its open property is the host's navigation, which opens the Document and reveals the Block. documentTitle is optional and turns a Document ID into a label. The panel appears only when the backend has the index capability. This is the reference app's layout:
const selectedFolder = ref('')
// Workspace search: a hit in the open document is revealed in place; any other hit opens its document, whose page reveals
// the block (`?block=`) once the editor has synced.
const route = useRoute()
const activeEditor = useActiveEditor()
const { documents } = useDocuments()
const documentTitle = (id: string) => documents.value.find(document => document.id === id)?.title
// Edits the chat proposed for other documents wait here for the user's decision, per document.
const changeSets = useChangeSets()
const openDocument = (id: string) => navigateTo(`/documents/${id}`)
// The Explore panel (backlinks, related blocks, trail; with a knowledge graph a fourth tab) shows the Block „Verknüpfungen anzeigen“
// in the inspector chose (`useExploreTarget`). The graph panel (`NUXT_PUBLIC_ADDONS_GRAPH=1`) draws the Edges around the open document, or around that Block.
const exploreTarget = useExploreTarget()
const { graph: graphEnabled } = addonFlagsOf(useRuntimeConfig().public.addons)
to="/kitchen-sink"
References to Blocks of other Documents
A reference chip (ReferenceAttrs) has an optional documentId. Without one it points into its own Document, as before. With one it points at a Block of another Document, and its label is the live title of that Block, read through backend.index.blockGet and refreshed in the background; the cached label only stands in while the Block cannot be resolved. The Markdown form puts the Document in front of the block ID, percent-encoded:
[@minutes-2026-10#0d7c9a0e-3f1a-4a4e-9c52-1b8a6d3f2e11]
[@minutes-2026-10#0d7c9a0e-3f1a-4a4e-9c52-1b8a6d3f2e11, S. 12]
The second line adds a locator. A token without a Document keeps the short form [@<block>]. A token with a Document always becomes a chip, because the importing Document cannot tell whether the Block exists; the index resolves it or the chip shows it as gone. Typing [@ offers Blocks of every Document through the search. The Markdown fence of a Typed block carries its block ID only when a reference chip or a field points at it, so the ID survives the Markdown round trip (ADR-0001).
A Derived block can name an original in another Document (DerivationSource.document). Whether it is stale is decided by comparing the inputHash its Derivation recorded with the sourceHash of the original's row, which the Block route also answers.
Document routes and the DocumentMutator
A documents port that implements the optional DocumentAdmin members (listTree, createDocument, moveDocument, next to deleteDocument) serves the Document routes. Without those members they answer 501. The list and the tree hide the Documents the user may not read.
| Route | Does |
|---|---|
GET /api/editor/documents | the Documents the user may read, newest first |
GET /api/editor/tree | the workspace as a folder tree |
POST /api/editor/documents | creates a Document ({ title?, folder?, templateId? }; a templateId copies a Dokumentvorlage, see Templates) |
PUT /api/editor/documents/:id/folder | moves a Document into a folder |
DELETE /api/editor/documents/:id | removes a Document with its Yjs state and its rows in the index |
POST /api/editor/documents/:id/ops | applies block operations ({ ops }), all or none |
The DocumentMutator behind the last route (createDocumentMutator, @skaile/forge-editor/server; EditorServer.mutator of the installed server) changes a Document nobody has to have open. The operations are those of the chat's edit_document tool (update, add, delete, convert). It checks each one against the block IDs of the Document, parses the Markdown without an editor (createMarkdownBlocks), writes an open Document inside its Y.Doc, so connected clients see an ordinary edit, and a stored Document through storeContent; the index is re-projected either way. Refusals are HttpErrors: 403 for a user without write access, 404 for an unknown Document, 409 when a block the operations name no longer exists, 422 for an invalid operation. Host code that changes a Document on a user's behalf calls the same method:
// server/api/documents/[id]/replace-block.post.ts
import { HttpError } from '@skaile/forge-editor/server'
import { useEditorServer } from '@skaile/forge-editor/server/nitro'
// A host route that changes a Document on a user's behalf. The mutator checks the user's right (`authorize`, 403), the block
// IDs (409 when a block is gone) and the operations (422), applies all or none, and keeps the Block index current.
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const user = await currentUser(event)
if (!id || !user) throw new HttpError(400, 'Document and user needed')
const { blockId, markdown } = await readBody<{ blockId: string, markdown: string }>(event)
const { mutator } = useEditorServer()
return mutator.apply(id, [{ type: 'update', id: blockId, markdown }], { user })
})
// Nuxt auto-imports and the host's session helper, declared so the file typechecks on its own.
declare function defineEventHandler(handler: (event: object) => Promise<unknown>): unknown
declare function getRouterParam(event: object, name: string): string | undefined
declare function readBody<T>(event: object): Promise<T>
declare function currentUser(event: object): Promise<object | null>
Tool providers
The document chat has one tool of its own, edit_document. A host can add read-only tools: tools of installEditorServer or provideEditorServer takes a list of ToolProviders (@skaile/forge-editor/server). A provider is { name, tool, handle(ctx, input) }: tool is the AI SDK definition the model sees (description and input schema, no execute), handle is what the server runs. The server feeds the returned text back to the model and lets it answer again. ToolContext holds the ports, the user behind the request, an abort signal, liveContent(id) and documentId (the Document open in the chat, absent for a chat without one); every read must stay inside what that user may read. toolRegistry(providers) indexes the list and throws for a missing or duplicate name and for edit_document; the install calls it, so a bad provider fails at start.
createWorkspaceTools(options?) provides two:
| Tool | Input | Answers |
|---|---|---|
find_blocks | query, limit? (10 by default) | the best hits over the Documents the user may read: address, Block type, title and the start of the text |
read_block | documentId, blockId | the Block as Markdown (6000 characters at most, cut with a note), read from the current content |
An unreadable Document and an unknown Block get the same answer from read_block, so the tool never confirms that a Block exists where the user may not look. Without the index port a call answers that the workspace has no index. A provider can be wrapped to add behaviour; the wrapper keeps the definition:
import { createWorkspaceTools, type ToolProvider } from '@skaile/forge-editor/server'
// A ToolProvider is a definition for the model (`name`, `tool`: description and input schema, no `execute`) and the handler
// the server runs. `handle` gets the model's raw input and answers the text the model reads. This wrapper keeps the
// definition and adds a log line to every call; the `ToolContext` holds the ports, the user, an abort signal, `liveContent` and the open `documentId`.
function logged(provider: ToolProvider): ToolProvider {
return {
...provider,
async handle(ctx, input) {
const answer = await provider.handle(ctx, input)
console.info(`[tool ${provider.name}] ${JSON.stringify(input)} -> ${answer.length} characters`)
return answer
},
}
}
// Register the result next to your own providers: `provideEditorServer({ ports, tools })`.
export const tools: ToolProvider[] = createWorkspaceTools().map(logged)
The reference app registers the tools in its Nitro plugin:
listModels: async () => stubModelEnabled() ? [...publicEnvPresets(), stubModelProfile] : null,
},
Change sets
When the server has a DocumentMutator and the host provides the changeSets port (EditorPorts.changeSets, a ChangeSetStore; createJsonAdapter({ dir }).changeSets keeps them in .change-sets.json), edit_document accepts an optional documentId on an operation. Such an operation aims at another Document, which the user must be able to read and edit. The chat does not apply it. The server stores the operations of one answer as a change set for the asking user, and the user decides each hunk (one operation on one Block of one Document) on its own.
A hunk is pending until it is decided, then accepted, rejected, stale (a block it names is gone or changed underneath, 409) or invalid (refused otherwise: 403, 404, 422, or a failure of the mutator that is no HTTP refusal, so the hunk is never applied blindly again). Accepting a hunk applies it through the DocumentMutator, so the checks above decide. Accepting claims the named hunks atomically, so two requests for the same hunk cannot both apply it. A decided hunk never changes again, and the model gets the outcomes as feedback with the next message. A change set belongs to the user whose chat proposed it (changeSetOwner(user) is the key: id, else username, else the serialized user); only that user lists and decides it.
| Route | Does |
|---|---|
GET /api/editor/change-sets | the user's change sets that still have a pending hunk, oldest first |
POST /api/editor/change-sets/:id/accept | accepts the named hunks ({ hunks: ['op3', ...] }) and answers the change set |
POST /api/editor/change-sets/:id/reject | rejects the named hunks; nothing is applied |
In the client, createChangeSetModel({ backend: backend.changeSets }) (@skaile/forge-editor/change-sets) is the model, and ChangeSetPanel renders it grouped by Document (@skaile/forge-editor/change-sets/vue or /react). The same model goes to the chat as the changeSets property of AiSidebar, so the chat can hand over a new change set and read the decisions. Edits to the open Document stay with the in-document Review. The reference app wires it once, in app/composables/useChangeSets.ts, and places the panel next to the search:
icon="i-lucide-blocks"
Embeds
A blockEmbed shows a Block of any Document, read-only, inside another Document. The Block has one home, the Document where it is stored and edited, and each embed is one mount of it. The embed stores no content: it is an atom node with documentId, blockId, mode (block or children, the Blocks nested in it) and a label, and its view reads the home Block. In the open Document itself the read is live and needs no request. For any other Document it goes through backend.index.blockGet with the Block's content, and the client polls: the server pushes nothing, so each mounted embed asks again every FOREIGN_CONTENT_REFRESH_MS (4 seconds), and an edit of the home Block shows in the other Document after at most about that long. A reference chip to another Document fetches only the summary, again when it is drawn after FOREIGN_BLOCK_REFRESH_MS (15 seconds) has passed since the last answer. An embed shows one of five states instead of content: loading, gone (the Block or its Document does not exist), unavailable (no index, a Document the user may not read, a failed read), cycle and limit.
The cycle guard follows the chain of Blocks an embed is rendered inside, outermost first. An embed whose home Block is already in that chain would render itself again, so it shows cycle. Embeds nest, so an embed can show a Block that holds embeds of its own, up to three levels deep and 24 mounted embeds under one outermost embed; past either bound an embed shows limit and neither reads nor follows its Block.
The Markdown form is on a line of its own, ids percent-encoded. Typing ![[ opens a popup that inserts one. blockEmbedMarkdown and referenceMarkdown write the two address forms (@skaile/forge-editor/composites):
import { blockEmbedMarkdown, referenceMarkdown } from '@skaile/forge-editor/composites'
import { compositeFeature } from '@skaile/forge-editor/composites/vue'
const address = { documentId: 'minutes-2026-10', blockId: '0d7c9a0e-3f1a-4a4e-9c52-1b8a6d3f2e11' }
// Both forms name a Block by its address (Document ID and block ID); the Document ID is percent-encoded.
export const embed = blockEmbedMarkdown(address)
// ![[minutes-2026-10#0d7c9a0e-3f1a-4a4e-9c52-1b8a6d3f2e11]]
export const embedChildren = blockEmbedMarkdown({ ...address, mode: 'children' })
// ![[minutes-2026-10#0d7c9a0e-3f1a-4a4e-9c52-1b8a6d3f2e11|children]]
export const chip = referenceMarkdown({ documentId: address.documentId, target: address.blockId, locator: 'S. 12' })
// [@minutes-2026-10#0d7c9a0e-3f1a-4a4e-9c52-1b8a6d3f2e11, S. 12]
// „Am Ursprung bearbeiten“ on an embed: the host opens the home Document with the Block revealed (`revealBlock`).
export const composites = compositeFeature({
openBlock: ({ documentId, blockId }) => { console.info(`open /documents/${documentId}?block=${blockId}`) },
})
To edit an embedded Block, the user edits it at its home. openBlock of compositeFeature receives the address when the user picks „Am Ursprung bearbeiten“ on an embed; without it the embed has no such action. The host decides what that means. The reference app reveals the Block when its Document is the open one and otherwise opens the Document with ?block=<id>, whose page reveals the Block (revealBlock) once the editor has synced:
// „Am Ursprung bearbeiten“ on an embedded block: the open document reveals the block in place, any other opens its document with
// `?block=` (the reveal above).
function openEmbedSource({ documentId, blockId }: BlockAddress) {
const instance = editor.value
What the host supplies
The library owns the ports, the routes, the panels' models, the references, the Embed and the tools. The host chooses the storage behind documents and index (and changeSets), the access rule (authenticate, authorize, readableDocuments), where a hit or an Embed's source opens, and which Block types and tools an editor gets. See also the guides on the editor ports, storage adapters and AI.
Prüfung and Freigabestatus
Record that a user checked a Typed block at one content state, derive the Freigabestatus, optionally lock checked blocks, and add your own chips and inspector sections.
EditorPorts: auth and storage
The single seam between the package and the host: ports for identity, documents, Yjs state, assets, settings, themes and AI models.