Add Document Generator SDK app + step-by-step tutorial (#22522)
## What & why
This adds a **guided tutorial** that teaches the Twenty SDK by building
one real, useful app end to end — plus the finished app itself, ready
for the marketplace.
The app, **Document Generator**, turns reusable templates into
personalized documents using CRM data: write a template once with
`{{placeholders}}`, then generate a filled-in document for any Person or
Company from the command menu, an AI agent, or a workflow.
## Two parts
**1. The app — `packages/twenty-apps/public/document-generator`**
Each capability maps to one tutorial chapter:
- **Data:** `documentTemplate` + `document` objects, fields, and a
bidirectional relation
- **Logic:** a single `generate-document` handler exposed as an **AI
tool**, a **workflow action**, and an **HTTP POST route**; plus a public
**HTML view route**
- **UI:** two views + sidebar navigation, a **command-menu item** (on
Person selection) that opens a **React front component**
- **AI:** an agent + skill; a default application role; marketplace
metadata + logo
- **Tests:** unit tests for the template renderer + an install
integration test
**2. The tutorial —
`packages/twenty-docs/.../apps/tutorials/document-generator/`**
A six-chapter series under **Developers › Apps › Tutorial** (Overview →
Data model → Generating documents → HTTP routes → Building the UI → AI
agent → Publishing). Minimal prose, paste-ready code, inline links to
the matching reference pages, and real screenshots. Registers a new
"Tutorial" nav group and regenerates `docs.json` + the navigation
template.
## Verification
Validated against a running Twenty instance (`twenty-app-dev` on
`:2020`):
- `twenty dev --once` installs cleanly (28 metadata objects created)
- Generated a real document from a Person — placeholders resolved (name,
job title, `company.name`, email), zero missing tokens
- Command menu → front component → generate flow works in the UI
- Public HTML view route renders the document
- App gates green: `yarn lint` (0/0), `yarn typecheck`, `yarn test:unit`
(7/7)
All screenshots in the tutorial are captured from this run.
## Notes
- Left out per-app CI workflows (`.github/workflows`) to keep scope
tight — happy to add them if wanted.
https://claude.ai/code/session_012FoC76zPbPmpgtN7MXMPwy
---
_Generated by [Claude
Code](https://claude.ai/code/session_012FoC76zPbPmpgtN7MXMPwy)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22522?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
---------
Co-authored-by: github-actions <github-actions@twenty.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: 5. An AI agent
|
||||
icon: "robot"
|
||||
description: Let an agent generate documents from a chat, using your tool.
|
||||
---
|
||||
|
||||
Because `generate-document` is exposed as a **tool**, an AI agent can call it.
|
||||
Let's add an agent and a skill so users can just say *"generate a proposal for
|
||||
Jeffery Griffin"*.
|
||||
|
||||
## The skill
|
||||
|
||||
A [skill](/developers/extend/apps/logic/skills-and-agents) is reusable
|
||||
instructions — knowledge you attach to agents. Ours teaches the model how to use
|
||||
the tool.
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'To generate a document, call the `generate-document` tool with:',
|
||||
'- `templateId`: the id of the document template to use.',
|
||||
'- `recordId`: the id of the Person or Company the document is for.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## The agent
|
||||
|
||||
An [agent](/developers/extend/apps/logic/skills-and-agents) pairs a prompt with a
|
||||
model. Set `responseFormat` explicitly to avoid a build warning.
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-assistant',
|
||||
label: 'Document Assistant',
|
||||
description: 'Generates documents from your templates and CRM records.',
|
||||
icon: 'IconFileText',
|
||||
responseFormat: { type: 'text' },
|
||||
prompt: [
|
||||
'You are the Document Assistant for a CRM.',
|
||||
'You help users generate personalized documents from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
The agent can only call the tool if its role allows it. We already set
|
||||
`canAccessAllTools: true` and `canBeAssignedToAgents: true` on the app's role in
|
||||
[Chapter 2](/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access).
|
||||
</Note>
|
||||
|
||||
## Try it
|
||||
|
||||
Open a chat with **Document Assistant** and ask it to draft a document for a
|
||||
person in your CRM. It finds the record, calls `generate-document`, and reports
|
||||
back the document it created — which now appears in your **Documents** view,
|
||||
exactly like the command-menu and workflow paths.
|
||||
|
||||
That's the payoff of exposing logic as a tool: **one function, many front doors** —
|
||||
command menu, HTTP, workflow step, and now natural language.
|
||||
|
||||
**After this step:** the app is feature-complete and genuinely useful. Time to
|
||||
ship it.
|
||||
|
||||
<Card title="Next: publishing →" icon="rocket" href="/developers/extend/apps/tutorials/document-generator/publishing">
|
||||
Add marketplace metadata and publish.
|
||||
</Card>
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
---
|
||||
title: 4. Building the UI
|
||||
icon: "table-columns"
|
||||
description: Views, sidebar navigation, a command, and front components.
|
||||
---
|
||||
|
||||
Right now the objects are only reachable through Settings. Let's give the app a
|
||||
real presence in the UI: list views, sidebar entries, a one-click
|
||||
**Generate document** command, a record-page front component to **preview** a
|
||||
document, and a native rich-text **editor** tab for templates.
|
||||
|
||||
## Views and navigation
|
||||
|
||||
A [view](/developers/extend/apps/layout/views) is a saved list of a given object.
|
||||
A [navigation menu item](/developers/extend/apps/layout/navigation-menu-items)
|
||||
puts that view in the sidebar.
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Documents',
|
||||
icon: 'IconFile',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
Add the same pair for templates. Both now show in the sidebar:
|
||||
|
||||
<Frame caption="Documents and Templates in the sidebar, with the generated document listed.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/04-documents-view.png" alt="Documents view with a generated document" />
|
||||
</Frame>
|
||||
|
||||
## A front component
|
||||
|
||||
A [front component](/developers/extend/apps/layout/front-components) is a React
|
||||
component sandboxed inside Twenty. Ours reads the selected record, loads the
|
||||
person templates via `CoreApiClient`, and POSTs to the route from the last
|
||||
chapter.
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Style with inline CSS variables (`var(--t-color-blue)`), not values imported from
|
||||
`twenty-ui`. The SDK mocks that package during build, so module-level imports of
|
||||
theme constants would be `undefined`. See the
|
||||
[full component](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx).
|
||||
</Warning>
|
||||
|
||||
## A command to open it
|
||||
|
||||
A [command menu item](/developers/extend/apps/layout/command-menu-items) with
|
||||
`availabilityType: 'RECORD_SELECTION'` shows up when a Person is selected, and
|
||||
opens the component in the side panel.
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## Try the whole flow
|
||||
|
||||
Open **People**, tick a person, and press <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd>.
|
||||
"Generate document" appears, tagged with your app:
|
||||
|
||||
<Frame caption="The command shows up when a Person is selected.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06-command-menu.png" alt="Command menu with Generate document" />
|
||||
</Frame>
|
||||
|
||||
Run it — your component opens in the side panel. Pick a template, click
|
||||
**Generate**, and a new record lands in **Documents**.
|
||||
|
||||
<Frame caption="The front component, loading templates and generating on click.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06b-front-component.png" alt="Generate document side panel" />
|
||||
</Frame>
|
||||
|
||||
Every generated document records your app as its author:
|
||||
|
||||
<Frame caption="Created by Document Generator, status Generated.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/05-document-record.png" alt="A generated document record" />
|
||||
</Frame>
|
||||
|
||||
## Preview a document on its record page
|
||||
|
||||
A front component isn't only for command menus — you can mount one as a **tab on a
|
||||
record page**. Let's add a *Preview* tab to the document record that renders the
|
||||
Markdown body as a polished, printable page.
|
||||
|
||||
The component reads the current record id from its execution context, loads the
|
||||
document, and renders it. Front components run in a **sandbox** that only allows a
|
||||
whitelist of HTML tags — raw HTML injection (`dangerouslySetInnerHTML`) and
|
||||
`<style>` are blocked — so we render the Markdown as React elements with inline
|
||||
styles via a small [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx)
|
||||
helper.
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
Mount it with a [page layout](/developers/extend/apps/layout/page-layouts). A
|
||||
`RECORD_PAGE` layout adds tabs to an object's record view; a `FRONT_COMPONENT`
|
||||
widget in a `CANVAS` tab hosts the component:
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Document preview',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
Open any document — a **Preview** tab renders it beautifully, with links to the
|
||||
shareable web page and the PDF:
|
||||
|
||||
<Frame caption="The Preview tab renders the document with inline styles, plus quick links.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/09-document-viewer.png" alt="Document viewer front component in a record-page tab" />
|
||||
</Frame>
|
||||
|
||||
## Edit a template with the rich-text editor
|
||||
|
||||
Templates don't need a custom component at all. Because the `body` is a
|
||||
`RICH_TEXT` field, Twenty already provides a full rich-text editor for it — the
|
||||
same one the standard Note and Task objects use. We just surface it on the
|
||||
template record page.
|
||||
|
||||
Add a tab with a `FIELD` widget in `EDITOR` display mode, pointing at the `body`
|
||||
field via `fieldMetadataId`:
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
A `RICH_TEXT` field stores both the editor's block JSON and a Markdown
|
||||
projection. The generation pipeline reads that Markdown projection, so
|
||||
placeholders, the PDF, and the shareable web page all keep working unchanged —
|
||||
see the full
|
||||
[`template-record.page-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts).
|
||||
Now editors write templates in a proper rich-text editor:
|
||||
|
||||
<Frame caption="The Template tab: Twenty's native rich-text editor bound to the body field.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/10-template-editor.png" alt="Template record with the native rich-text editor tab" />
|
||||
</Frame>
|
||||
|
||||
**After this step:** documents preview beautifully and templates are editable
|
||||
in-app. Next, let an AI agent generate them from a chat.
|
||||
|
||||
<Card title="Next: an AI agent →" icon="robot" href="/developers/extend/apps/tutorials/document-generator/ai-agent">
|
||||
Add an agent and a skill that call your tool.
|
||||
</Card>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: 1. Data model
|
||||
icon: "database"
|
||||
description: Model documents and templates with objects, fields, and a relation.
|
||||
---
|
||||
|
||||
Our app needs two custom objects: **document templates** (what to write) and
|
||||
**documents** (the generated result). Let's define them.
|
||||
|
||||
Scaffold each entity file with the CLI — it generates a valid UUID and the right
|
||||
folder for you:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
Below we show the finished files.
|
||||
|
||||
<Note>
|
||||
Every `*_UNIVERSAL_IDENTIFIER` constant lives in
|
||||
`src/constants/universal-identifiers.ts` and is imported where used. The snippets
|
||||
below omit those imports for brevity — keep them in your own files.
|
||||
</Note>
|
||||
|
||||
## The template object
|
||||
|
||||
A template has a `name`, a `body` with `{{placeholders}}`, and a `target` that
|
||||
says whether it's written for a Person or a Company. The `body` is a
|
||||
`RICH_TEXT` field, so Twenty gives it a full rich-text editor.
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`SELECT` option **values** must be `UPPER_CASE` (`PERSON`, not `person`), and the
|
||||
`defaultValue` is wrapped in extra quotes: `` `'PERSON'` ``. The `label` is what
|
||||
users see.
|
||||
</Warning>
|
||||
|
||||
## The document object
|
||||
|
||||
The generated document stores the rendered `content` and a `status`. Define it
|
||||
the same way, with a `status` select of `DRAFT` / `GENERATED`. Full file:
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
|
||||
|
||||
## Linking them with a relation
|
||||
|
||||
Each document should point back to the template it came from. Relations are
|
||||
**bidirectional** — you define both sides, each in its own field file.
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The other side (`template-documents-relation.field.ts`) is a
|
||||
`RelationType.ONE_TO_MANY` field named `documents` that points the opposite way.
|
||||
See [Relations](/developers/extend/apps/data/relations) for the full pattern.
|
||||
|
||||
## See it in Twenty
|
||||
|
||||
With `yarn twenty dev` running, open **Settings → Data model**. Both objects
|
||||
appear, tagged with your app.
|
||||
|
||||
<Frame caption="Both custom objects, owned by the Document Generator app.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/01-data-model.png" alt="Data model settings showing Documents and Document templates" />
|
||||
</Frame>
|
||||
|
||||
Create one template to test with — name it *Sales proposal*, set **Target** to
|
||||
*Person*, and paste a body with a few placeholders:
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="A template record. The body keeps its placeholders until a document is generated.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/03-template-record.png" alt="A Sales proposal template record with placeholder body" />
|
||||
</Frame>
|
||||
|
||||
**After this step:** you have `documentTemplate` and `document` objects, linked by
|
||||
a relation, and one template to generate from. Next, the logic that fills it in.
|
||||
|
||||
<Card title="Next: generating documents →" icon="bolt" href="/developers/extend/apps/tutorials/document-generator/generating-documents">
|
||||
Write the logic function that fills the template.
|
||||
</Card>
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
---
|
||||
title: 2. Generating documents
|
||||
icon: "bolt"
|
||||
description: One logic function, exposed as an AI tool and a workflow action.
|
||||
---
|
||||
|
||||
Now the core: a [logic function](/developers/extend/apps/logic/logic-functions)
|
||||
that loads a template and a record, fills the placeholders, and saves a new
|
||||
document.
|
||||
|
||||
We'll write the business logic once as a **handler**, then expose it through
|
||||
several triggers. This chapter wires up two of them — an **AI tool** and a
|
||||
**workflow action**.
|
||||
|
||||
## The rendering helper
|
||||
|
||||
Keep pure logic in its own file so it's easy to unit-test. This flattens a record
|
||||
into `{{dot.path}}` tokens and substitutes them.
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
const PLACEHOLDER_PATTERN = /\{\{\s*([\w.]+)\s*\}\}/g;
|
||||
|
||||
export const renderTemplate = (body: string, values: Record<string, string>) => {
|
||||
const missingTokens = new Set<string>();
|
||||
const content = body.replace(PLACEHOLDER_PATTERN, (_m, token: string) => {
|
||||
const value = values[token];
|
||||
if (value === undefined || value === '') { missingTokens.add(token); return ''; }
|
||||
return value;
|
||||
});
|
||||
return { content, missingTokens: [...missingTokens] };
|
||||
};
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Because this file has no side effects, you can cover it with fast unit tests
|
||||
(`yarn test:unit`). See [Testing](/developers/extend/apps/operations/testing).
|
||||
</Tip>
|
||||
|
||||
## The handler
|
||||
|
||||
The handler uses the generated [`CoreApiClient`](/developers/extend/apps/logic/logic-functions)
|
||||
to read and write CRM data. It loads the template, loads the target record, fills
|
||||
the body, and creates a `document`.
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { loadRecordValues } from 'src/logic-functions/utils/load-record-values';
|
||||
import { renderTemplate } from 'src/logic-functions/utils/render-template';
|
||||
|
||||
export const generateDocumentHandler = async (
|
||||
input: { templateId: string; recordId: string },
|
||||
) => {
|
||||
const client = new CoreApiClient();
|
||||
|
||||
// Use a filtered list query, not the singular lookup: the singular query
|
||||
// throws when nothing matches, which would become a 500 instead of a 404.
|
||||
const { documentTemplates } = await client.query({
|
||||
documentTemplates: {
|
||||
__args: { filter: { id: { eq: input.templateId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, body: true, target: true } },
|
||||
},
|
||||
});
|
||||
const documentTemplate = documentTemplates?.edges?.[0]?.node;
|
||||
if (!documentTemplate?.id) return { success: false, status: 404, message: 'Template not found.' };
|
||||
|
||||
const record = await loadRecordValues(client, documentTemplate.target, input.recordId);
|
||||
if (!record.found) return { success: false, status: 404, message: 'Record not found.' };
|
||||
|
||||
const { content, missingTokens } = renderTemplate(documentTemplate.body ?? '', record.values);
|
||||
|
||||
const { createDocument } = await client.mutation({
|
||||
createDocument: {
|
||||
__args: { data: {
|
||||
name: `${documentTemplate.name} — ${record.displayName}`,
|
||||
content, status: 'GENERATED', templateId: documentTemplate.id,
|
||||
} },
|
||||
id: true, name: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, documentId: createDocument.id, content, missingTokens };
|
||||
};
|
||||
```
|
||||
|
||||
`loadRecordValues` runs a different query for a Person vs. a Company and flattens
|
||||
the result — see
|
||||
[`load-record-values.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/load-record-values.ts).
|
||||
|
||||
## Expose it as a tool and a workflow action
|
||||
|
||||
A single `defineLogicFunction` can carry several triggers. Here, `toolTriggerSettings`
|
||||
makes it callable by AI agents, and `workflowActionTriggerSettings` turns it into a
|
||||
step in the visual workflow builder. Both describe their input with a JSON schema.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document.ts"
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-sdk/logic-function';
|
||||
import { GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
import { generateDocumentInputSchema } from 'src/logic-functions/schemas/generate-document-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document',
|
||||
description: 'Generate a document from a template and a CRM record.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: generateDocumentInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Generate Document',
|
||||
icon: 'IconFileText',
|
||||
inputSchema: jsonSchemaToInputSchema(generateDocumentInputSchema),
|
||||
outputSchema: [{ type: 'object', properties: {
|
||||
success: { type: 'boolean' }, documentId: { type: 'string' },
|
||||
} }],
|
||||
},
|
||||
handler: generateDocumentHandler,
|
||||
});
|
||||
```
|
||||
|
||||
The input schema is a plain JSON schema describing `templateId` and `recordId` —
|
||||
see [`generate-document-input.schema.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/schemas/generate-document-input.schema.ts).
|
||||
|
||||
## Grant it access
|
||||
|
||||
Logic functions run as the app's role. It needs to read templates and records
|
||||
and create documents, so allow that in `src/roles/default-role.ts`:
|
||||
|
||||
```ts
|
||||
export default defineApplicationRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Document Generator default role',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canAccessAllTools: true,
|
||||
canBeAssignedToAgents: true,
|
||||
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
|
||||
});
|
||||
```
|
||||
|
||||
`UPLOAD_FILE` lets the function upload the generated PDF in the next section.
|
||||
See [Roles](/developers/extend/apps/config/roles) for finer-grained permissions.
|
||||
|
||||
## Attach a real PDF file
|
||||
|
||||
A rendered text field is useful, but users want a real document. Let's generate a
|
||||
**PDF** and store it on the record as a downloadable file.
|
||||
|
||||
First, give the `document` object a `FILES` field to hold the PDF. Apps upload
|
||||
into their **own** files fields, so this field is what routes the upload:
|
||||
|
||||
```ts filename="src/objects/document.object.ts"
|
||||
{
|
||||
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'file',
|
||||
label: 'File',
|
||||
icon: 'IconFileTypePdf',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
}
|
||||
```
|
||||
|
||||
Now render that PDF. An app is a real Node project, so you can add any npm
|
||||
package you need and import it like anywhere else. We use **[pdf-lib](https://pdf-lib.js.org/)**
|
||||
to draw the PDF and **[marked](https://marked.js.org/)** to parse the Markdown
|
||||
body — the CLI installs them into the function's runtime for you:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn add pdf-lib marked
|
||||
```
|
||||
|
||||
The full helper is
|
||||
[`generate-document-pdf.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/generate-document-pdf.ts).
|
||||
It parses the Markdown into tokens with `marked.lexer`, then lays them out with
|
||||
pdf-lib: real headings, **bold**/*italic* runs, bullet and numbered lists,
|
||||
blockquotes and rules — a polished, multi-page A4 rendering of the template
|
||||
itself, rather than a wall of text.
|
||||
|
||||
<Frame caption="The generated PDF: real typography and Markdown formatting, rendering the template body.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07b-generated-pdf.png" alt="A polished, marketable generated PDF" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
pdf-lib's built-in fonts use WinAnsi encoding, so Western-European accents render
|
||||
out of the box; the helper maps smart quotes and dashes and drops characters it
|
||||
can't encode. Rendering non-Latin scripts (Chinese, Arabic, Cyrillic) would mean
|
||||
embedding a Unicode font.
|
||||
</Note>
|
||||
|
||||
Then upload it and store the reference on the record. `uploadFile` routes bytes
|
||||
to your app-owned files field; the returned `id` is what you save:
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
import { generateDocumentPdf } from 'src/logic-functions/utils/generate-document-pdf';
|
||||
|
||||
const documentName = `${documentTemplate.name} — ${record.displayName}`;
|
||||
const bytes = await generateDocumentPdf(documentName, content);
|
||||
const fileName = 'proposal.pdf';
|
||||
|
||||
const uploaded = await new MetadataApiClient().uploadFile(
|
||||
Buffer.from(bytes),
|
||||
fileName,
|
||||
'application/pdf',
|
||||
DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
);
|
||||
|
||||
await client.mutation({
|
||||
updateDocument: {
|
||||
__args: {
|
||||
id: documentId,
|
||||
data: { file: [{ fileId: uploaded.id, label: fileName }] },
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The generated document now carries a downloadable PDF:
|
||||
|
||||
<Frame caption="The generated PDF, stored on the document's File field.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/08-document-with-pdf.png" alt="A document record with a generated PDF file" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
`uploadFile` only targets **app-owned** files fields (so uploads always require an
|
||||
app that owns the field, plus the `UPLOAD_FILE` role flag). That's why the PDF
|
||||
lands on the record's own `file` field — the same pattern the
|
||||
[call-recorder app](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
|
||||
uses for recordings.
|
||||
</Note>
|
||||
|
||||
**After this step:** each generated document has a real, downloadable PDF. But
|
||||
nothing can *call* the generator from the UI yet — for that we need an HTTP route.
|
||||
|
||||
<Card title="Next: HTTP routes →" icon="globe" href="/developers/extend/apps/tutorials/document-generator/http-routes">
|
||||
Serve the function over HTTP and render documents as web pages.
|
||||
</Card>
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: 3. HTTP routes
|
||||
icon: "globe"
|
||||
description: Trigger the function over HTTP and render documents as web pages.
|
||||
---
|
||||
|
||||
The same handler can also answer HTTP requests. We'll add two routes:
|
||||
|
||||
- a **POST** endpoint the UI calls to generate a document, and
|
||||
- a public **GET** endpoint that renders a document as a printable web page.
|
||||
|
||||
Both use `httpRouteTriggerSettings`. App routes are served under `/s` on your
|
||||
Twenty server (e.g. `http://localhost:2020/s/documents/generate`).
|
||||
|
||||
## POST route — generate on demand
|
||||
|
||||
This reuses `generateDocumentHandler`, so there's no logic to repeat — just a thin
|
||||
adapter that reads the request body.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result.success ? 200 : (result.status ?? 400),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The shared handler returns a suggested `status` on failure, so the route can
|
||||
answer with a proper `4xx`/`5xx` code. `isAuthRequired: true` means the caller
|
||||
must present a valid token — the front component in the next chapter passes the
|
||||
user's access token automatically.
|
||||
|
||||
## GET route — render as a web page
|
||||
|
||||
To return HTML instead of JSON, wrap the body in a `Response` with a
|
||||
`Content-Type` header. This route is public (`isAuthRequired: false`) so a
|
||||
generated document can be shared as a link.
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { documentHtmlPage } from 'src/utils/render-document';
|
||||
|
||||
const htmlResponse = (html: string, status = 200): Response =>
|
||||
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: documentId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, content: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const document = documents?.edges?.[0]?.node;
|
||||
if (!document?.id) {
|
||||
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
|
||||
}
|
||||
|
||||
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'view-document',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`documentHtmlPage` renders the Markdown body to HTML (with [marked](https://marked.js.org/),
|
||||
sanitized) and drops it into a clean, printable page that shows just the template
|
||||
content — the same look as the PDF and the in-app preview.
|
||||
[See the helper](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
|
||||
|
||||
## Try it
|
||||
|
||||
With a template and a Person in your workspace, call the route (grab a token from
|
||||
**Settings → APIs & Webhooks**):
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
Open the returned document in your browser:
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="The public GET route renders the document as a printable page.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="A rendered document web page" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
You can also stream a function's logs while testing with
|
||||
`yarn twenty dev:function:logs`, or invoke it directly with
|
||||
`yarn twenty dev:function:exec`.
|
||||
</Tip>
|
||||
|
||||
**After this step:** the app can generate documents over HTTP and serve them as
|
||||
web pages. Now let's make it usable without `curl`.
|
||||
|
||||
<Card title="Next: building the UI →" icon="table-columns" href="/developers/extend/apps/tutorials/document-generator/building-the-ui">
|
||||
Views, navigation, a command, and a front component.
|
||||
</Card>
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "Tutorial: Document Generator"
|
||||
icon: "wand-magic-sparkles"
|
||||
description: Build a real Twenty app that generates personalized documents from your CRM data.
|
||||
---
|
||||
|
||||
In this tutorial you'll build **Document Generator** — an app that turns reusable
|
||||
templates into personalized documents using the data already in your CRM.
|
||||
|
||||
Write a template once with `{{placeholders}}`, then generate a filled-in
|
||||
document for any Person or Company in one click — from the command menu, from an
|
||||
AI agent, or from a workflow.
|
||||
|
||||
<Frame caption="One template, generated for a specific person, opened as a printable page.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="A generated Sales proposal document" />
|
||||
</Frame>
|
||||
|
||||
## What you'll learn
|
||||
|
||||
Each chapter adds one capability. By the end you'll have touched most of the SDK.
|
||||
|
||||
| Chapter | Capability | Reference |
|
||||
|---|---|---|
|
||||
| [1. Data model](/developers/extend/apps/tutorials/document-generator/data-model) | Objects, fields, and a relation | [Data](/developers/extend/apps/data/overview) |
|
||||
| [2. Generating documents](/developers/extend/apps/tutorials/document-generator/generating-documents) | A logic function (AI tool + workflow action) that fills a Markdown template and attaches a polished PDF | [Logic functions](/developers/extend/apps/logic/logic-functions) |
|
||||
| [3. HTTP routes](/developers/extend/apps/tutorials/document-generator/http-routes) | Serving JSON and a shareable HTML page from routes | [Logic functions](/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. Building the UI](/developers/extend/apps/tutorials/document-generator/building-the-ui) | Views, navigation, command menu, and front components that preview a document and edit a template | [Layout](/developers/extend/apps/layout/overview) |
|
||||
| [5. An AI agent](/developers/extend/apps/tutorials/document-generator/ai-agent) | Agent + skill | [Skills & agents](/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. Publishing](/developers/extend/apps/tutorials/document-generator/publishing) | Ship it to the marketplace | [Publishing](/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You should have finished the [Quick Start](/developers/extend/apps/getting-started/quick-start):
|
||||
a local Twenty server running on port `2020` and the CLI authenticated to it.
|
||||
|
||||
If not, scaffold and start one now:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
Prefer to read the finished code? The complete app lives in
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
Every snippet below is copied from it.
|
||||
</Note>
|
||||
|
||||
## How the app fits together
|
||||
|
||||
<Frame>
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="A template with placeholders is generated into a polished document with a PDF, triggered from the command menu, an AI agent, a workflow, or a shareable link" />
|
||||
</Frame>
|
||||
|
||||
You write a **template** once in a rich-text editor, with `{{placeholders}}`. Picking a
|
||||
template and a CRM record fills the placeholders and stores a polished
|
||||
**document** (with a PDF file). Everything else — the command menu, the AI agent,
|
||||
the workflow step, the shareable link — is just a different way to trigger that
|
||||
one generator.
|
||||
|
||||
## Keep this loop running
|
||||
|
||||
Leave `yarn twenty dev` running in a terminal for the whole tutorial. Every time
|
||||
you add or edit a file under `src/`, it re-syncs to your server within a few
|
||||
seconds, so you can watch each capability appear in the UI as you build it.
|
||||
|
||||
<Card title="Start building →" icon="database" href="/developers/extend/apps/tutorials/document-generator/data-model">
|
||||
Chapter 1: model documents and templates.
|
||||
</Card>
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: 6. Publishing
|
||||
icon: "rocket"
|
||||
description: Add marketplace metadata and publish your app.
|
||||
---
|
||||
|
||||
Your app works. The last step is to describe it for the marketplace and publish.
|
||||
|
||||
## Add marketplace metadata
|
||||
|
||||
The [application config](/developers/extend/apps/config/application) carries the
|
||||
identity that shows up in the marketplace: author, category, logo, and support
|
||||
links. Put a logo in `public/` and reference it with `logoUrl`.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
The default role is declared with `defineApplicationRole()` in its own file — you
|
||||
don't pass `defaultRoleUniversalIdentifier` here anymore.
|
||||
</Tip>
|
||||
|
||||
Also add the `twenty-app` keyword to `package.json` so the app is discoverable:
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## Add gallery screenshots
|
||||
|
||||
A marketplace listing sells itself with screenshots. Drop a few PNGs in
|
||||
`public/gallery/` and reference them with `screenshots` — they render as a gallery
|
||||
on the listing page.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Lead with the payoff: make the first screenshot the finished result (a generated
|
||||
document), then show how it's triggered and authored. Use crisp, high-resolution
|
||||
captures — they're the first thing a user sees.
|
||||
</Tip>
|
||||
|
||||
Give `README.md` the same treatment — it's the front page on npm and GitHub.
|
||||
Open with the value proposition and a screenshot, list the headline features,
|
||||
then keep the build details below the fold.
|
||||
|
||||
## Check before you ship
|
||||
|
||||
Run the same gates CI does:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
The dry run prints exactly what would change on the server without applying it —
|
||||
a good final sanity check. See
|
||||
[Testing](/developers/extend/apps/operations/testing) and
|
||||
[Syncing & recovery](/developers/extend/apps/operations/sync-and-recovery).
|
||||
|
||||
## Publish
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
`app:publish` builds and publishes to npm by default; `--private` uploads a
|
||||
tarball to a Twenty server's private registry instead. To surface a published app
|
||||
in an instance's marketplace, trigger a catalog sync:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
Full details and the release checklist:
|
||||
[Publishing](/developers/extend/apps/operations/publishing).
|
||||
|
||||
## You built an app 🎉
|
||||
|
||||
In six chapters you used most of the SDK surface:
|
||||
|
||||
- **Objects, fields, and a relation** to model the data
|
||||
- A **logic function** exposed as an **AI tool**, a **workflow action**, and **HTTP routes**
|
||||
- **Views, navigation, a command, and a front component** for the UI
|
||||
- An **agent + skill** for natural-language generation
|
||||
- **Marketplace metadata** and the publish flow
|
||||
|
||||
The finished app is at
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
|
||||
## Where to go next
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Data reference" icon="database" href="/developers/extend/apps/data/overview">
|
||||
Every field type, relation, and index option.
|
||||
</Card>
|
||||
<Card title="Logic reference" icon="bolt" href="/developers/extend/apps/logic/overview">
|
||||
Cron and database-event triggers, the key-value store, OAuth connections.
|
||||
</Card>
|
||||
<Card title="Layout reference" icon="table-columns" href="/developers/extend/apps/layout/overview">
|
||||
Page layouts, dashboard widgets, and more UI surfaces.
|
||||
</Card>
|
||||
<Card title="Operations" icon="rocket" href="/developers/extend/apps/operations/overview">
|
||||
CLI, testing, remotes, and CI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
Reference in New Issue
Block a user