Skill: Create a Custom Document
For AI coding agents: This page is a dense reference checklist optimized for tools like Claude Code scaffolding Loopstack code. For the human-readable guide, see Creating Documents.
Document Anatomy
A document is a plain TypeScript DTO decorated with @Document(). It pairs a Zod schema (for content validation) with an optional YAML widget config (for Studio rendering).
import { z } from 'zod';
import { Document } from '@loopstack/common';
export const NotesSchema = z.object({
text: z.string(),
});
@Document({
schema: NotesSchema,
widget: __dirname + '/notes.ui.yaml',
})
export class NotesDocument {
text: string;
}# notes.ui.yaml
type: document
ui:
widgets:
- widget: form
options:
properties:
text:
title: Notes
widget: textarea
rows: 8Documents are NOT NestJS providers. Unlike
@Tooland@Workflow,@Documentdoes not apply@Injectable(). Do not add document classes to a module’sprovidersarray. Do not inject them. Reference the class directly:documentStore.save(NotesDocument, …).
@Document() Options
All options are optional.
| Option | Type | Default | Description |
|---|---|---|---|
name | string | class name with Document suffix stripped, snake_cased | Explicit snake_case identifier. E.g. AskUserDocument → ask_user. |
title | string | — | Display title shown in Studio. |
description | string | — | Description shown in Studio. |
widget | WidgetRef | WidgetRef[] | — | YAML file path(s) — or inline widget object(s) — defining how the document renders. |
schema | z.ZodType | — | Zod schema validating document content on documentStore.save(). |
tags | string[] | — | Default tags assigned to every instance. |
meta | StaticDocumentMeta | — | Static metadata served via the config endpoint, not persisted per instance. |
Saving Documents
documentStore is auto-injected on BaseWorkflow and BaseTool. Reference document classes directly — no injection.
// Create a new document
await this.documentStore.save(NotesDocument, { text: 'Hello!' });
// Create or update with a specific ID (idempotent)
await this.documentStore.save(NotesDocument, { text: 'Updated' }, { id: 'notes-1' });
// Hidden from the UI (still persisted)
await this.documentStore.save(NotesDocument, { text: 'Internal' }, { meta: { hidden: true } });Save an instance instead
const draft = this.documentStore.create(NotesDocument, { text: 'Initial' });
draft.text += '\n\nMore.';
await this.documentStore.save(draft); // overload: pre-built instance
await this.documentStore.save(draft, { id: 'notes-1' }); // with save optionscreate() returns a typed class instance with the data attached. Nothing is persisted until save().
Save Options
| Option | Type | Description |
|---|---|---|
id | string | Custom ID — passing the same ID twice updates the same record. |
meta.hidden | boolean | Hide the document from the Studio UI. |
Querying Documents
All methods return only non-invalidated documents for the current run.
| Method | Returns | Use for |
|---|---|---|
findAll(MyDocument) | MyDocument[] | Typed hydrated instances of one document type. |
findByTag('tag') | DocumentEntity[] | All documents tagged with tag (across types). |
findAllDocuments() | DocumentEntity[] | Every document in this run, raw entities. |
const notes = this.documentStore.findAll(NotesDocument); // typed
const tagged = this.documentStore.findByTag('message');
const all = this.documentStore.findAllDocuments();documentStore.create(MyDocument, data) returns a typed instance without persisting — validates against the Zod schema only.
Reusing Built-in Documents
Don’t redefine these — import and save:
| Document | Source | Key fields |
|---|---|---|
LlmMessageDocument | @loopstack/llm-provider-module | role, text, blocks |
LinkDocument | @loopstack/common | label, workflowId, embed, expanded |
MessageDocument | @loopstack/common | role, text |
MarkdownDocument | @loopstack/common | markdown |
PlainDocument | @loopstack/common | text |
ErrorDocument | @loopstack/common | error |
import { MarkdownDocument } from '@loopstack/common';
import { LlmMessageDocument } from '@loopstack/llm-provider-module';
await this.documentStore.save(LlmMessageDocument, { role: 'assistant', text: 'Hello!' });
await this.documentStore.save(MarkdownDocument, { markdown: '# Report\n- A\n- B' });YAML Widget Config
The widget option points to a YAML file describing how the document renders in Studio. The most common widget is form:
type: document
ui:
widgets:
- widget: form
options:
order: [name, description, items]
properties:
name:
title: Name
description:
title: Description
widget: textarea
items:
title: Items
collapsed: true
items:
title: Item
actions:
- type: button
transition: submit
label: 'Submit'Widget Types
Used in options.properties.<field>.widget:
| Widget | Description |
|---|---|
text | Single-line text (default) |
textarea | Multi-line text |
select | Dropdown |
radio | Radio buttons |
checkbox | Checkbox |
switch | Toggle |
slider | Numeric slider |
code-view | Syntax-highlighted read-only code |
Property Options
| Option | Type | Description |
|---|---|---|
title | string | Display label |
widget | string | Widget type (see above) |
placeholder | string | Placeholder text |
rows | number | Visible rows (textarea) |
readonly | boolean | Read-only field |
hidden | boolean | Hide the field |
disabled | boolean | Disable interaction |
collapsed | boolean | Collapse arrays/objects by default |
items | object | UI config for array items |
Actions
Buttons that trigger wait: true transitions:
actions:
- type: button
transition: confirm # must match the transition method name
label: ConfirmChecklist
- Decide if a built-in document covers your need first — don’t reinvent
MessageDocument,MarkdownDocument, etc. - Create the class file with
@Document({ schema, widget }). - Define the Zod schema next to or above the class.
- Add the YAML widget config beside the
.tsfile (e.g.notes.ui.yaml). - Do not add the class to module
providers. Reference it directly viadocumentStore.save(MyDocument, …). - Save via
documentStore.save(MyDocument, content, options?)from a workflow transition or tool.