Document Workspace

Applies to: ChengOS v0.1.0+ | Last verified: 2026-08-12 | Source: crates/cheng-core/src/domain/document, crates/cheng-nodes/src/nodes/builtin/document, chengflow-ui/src/features/document, chengflow-ui/src/features/shadow

A ChengOS document is not a Markdown file. It is a block tree stored in the database, with its own version history and a staging area — the shadow session — that lets an agent propose edits without touching the document until a human accepts them.

That design is what makes documents safe to hand to a model.

The domain model

Document (aggregate root)
  ├── BlockTree
  │     └── Block
  │           ├── BlockType     (paragraph, heading1..3, code, ...)
  │           ├── BlockContent  (text, code, todo, image, callout, ...)
  │           └── BlockMeta     (priority, tags)
  ├── DocumentVersion[]         (history snapshots)
  └── ShadowSession?            (staged, not yet applied)
        └── ShadowOperation[]

Every block has a stable id, a parent, and an ordered position among its siblings. Editing a document therefore means addressing a block, not a character offset — which is exactly what makes an LLM edit reviewable: the diff is a list of blocks, not a text patch.

Block types: paragraph, heading1, heading2, heading3, code, bullet_list, numbered_list, todo_item, quote, callout, image, table_view, embed, divider.

Block metadata carries two fields that matter far more than they look:

These are not decoration. They are the filter axis used when feeding a document to a model (see Feeding a document to an LLM below), so a 40-page document can be reduced to "only the critical blocks tagged spec" without a retrieval pipeline.

Documents in the editor

The document workbench lives beside the canvas: a document list, tabs for open documents, and a block editor. Documents belong to a workspace, and the active workspace is injected into node execution automatically — most document nodes leave workspace_id blank and inherit it.

The document nodes

Seven nodes are visible on the canvas; the rest are internal machinery used by the hub.

NodeWhat it does
document/createCreates a document, optionally with a first block
document/queryReads a document — as blocks, or as Markdown with as_markdown
document/update_blockReplaces one block's content or type
document/format_for_llmRenders a document for a model, with filtering
document/extract_itemsTurns block tags and lines into loop-ready items
document/ops_hubThe whole document toolkit, exposed to an agent

document/query returns both the block list and derived counts (block_count, character_count), plus plain_text — enough to branch on document size before deciding how to process it.

The document_id field is a dynamic select: the dropdown is populated from GET /api/v1/documents?limit=200, which the X-Workspace-Id middleware has already scoped to the active workspace. You pick a document by title, and the node stores its UUID.

Feeding a document to an LLM

document/format_for_llm exists because dumping a whole document into a prompt is usually the wrong move. It takes four knobs:

OptionValuesDefault
priority_filterall, high_only, critical_only, normal_and_aboveall
tag_filtercomma-separated tagsnone
sort_orderoriginal, priority_desc, priority_ascoriginal
output_formatmarkdown, plain_text, structuredmarkdown

Plus max_chars (0 = unlimited) and include_metadata. structured keeps block ids in the output, which is what you want when the model is expected to edit the document — it needs the ids to address its own changes back.

Shadow sessions: safe AI editing

Every write an agent makes goes through a shadow session rather than straight into the tree.

shadow_create ──► shadow_write ──► shadow_complete ──► shadow_await_review
    (fork)          (stage ops)      (generating→        (blocks until a
                                      pending_review)      human decides)
                                                                │
                                              accept ───────────┴─────── reject
                                                 │                          │
                                              merged                    discarded

Shadow status: generating, pending_review, merged, discarded, partially_accepted, expired. merged, discarded, and expired are terminal.

partially_accepted is the interesting one — review is per-block, so a user can take three of an agent's five edits and leave the rest pending.

In the UI this surfaces as the shadow review bar and a block-level diff viewer: staged insertions, updates, and deletions are overlaid on the real document, and there is a conflict resolution modal for when the underlying document moved while the agent was writing.

Two consequences worth internalising:

  1. A write operation from the hub can return requires_review: true with a shadow_id. The execution enters waiting_for_review and stays there durably until a decision arrives — see the execution model.
  2. Every write operation accepts dry_run: true, which stages and returns the diff without applying anything. This is the cheap way to let a model check its own work.

The operations hub

document/ops_hub is one node connected to an agent's tools port that exposes the entire document toolkit. The agent picks a capability through the operation field; the node's toggle switches decide which operations exist at all.

Read operations (default on): query, format_for_llm, document_info, list_documents, search_documents, diff_document, export_document.

Write operations (default off, each behind its own switch): create, create_block, update_block, replace_text, delete_block, move_block, batch_ops, rename_document, duplicate_document, import_document, delete_document, restore_from_export.

An operation whose switch is off fails immediately with no side effect — the agent cannot talk its way past the toggle, because the check happens before the operation runs.

The call shape is operation and document_id at the top level, everything else inside config. Flat top-level parameters are also accepted and folded into config, because models produce both shapes.

Three operations deserve a callout:

Turning a document into loop items

document/extract_items converts a document's tags and lines into an array a loop or batch subflow can iterate. Choose what to extract with target — tags, lines, pairs, objects, groups — and how to combine it with combine: list, sequential, random, selected, zip, or cartesian (capped at 10,000 items).

The practical pattern: tag blocks in a document by the task they represent, extract pairs, and feed them to a batch subflow that processes each one.

Next steps