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:
martmull
2026-07-07 11:13:34 +02:00
committed by GitHub
parent 2a495c3477
commit 07a921f8ca
73 changed files with 7344 additions and 0 deletions
@@ -0,0 +1,124 @@
import { type CSSProperties, useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
import { DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { Markdown } from 'src/utils/markdown-to-react';
const useCurrentRecordId = (): string | null =>
useFrontComponentExecutionContext((context) =>
context.recordId ??
(context.selectedRecordIds.length === 1
? context.selectedRecordIds[0]
: null),
);
const styles: Record<string, CSSProperties> = {
scroll: { height: '100%', overflow: 'auto', background: '#eef1f6', padding: '24px' },
paper: {
maxWidth: '720px',
margin: '0 auto',
background: '#ffffff',
borderRadius: '10px',
boxShadow: '0 10px 40px rgba(24, 39, 75, 0.08)',
overflow: 'hidden',
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif",
},
body: { padding: '48px 56px 56px' },
empty: { padding: '40px', textAlign: 'center', color: '#6b7280', fontFamily: 'sans-serif' },
actions: {
maxWidth: '720px',
margin: '0 auto 16px',
display: 'flex',
gap: '10px',
justifyContent: 'flex-end',
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif",
},
actionLink: {
fontSize: '13px',
fontWeight: 500,
color: '#1961ed',
background: '#ffffff',
border: '1px solid #d7deee',
borderRadius: '6px',
padding: '6px 12px',
textDecoration: 'none',
},
};
type LoadedDocument = { name: string; content: string; pdfUrl?: string };
const DocumentViewer = () => {
const recordId = useCurrentRecordId();
const [document, setDocument] = useState<LoadedDocument | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!recordId) {
setLoading(false);
return;
}
// Reset while the newly-selected document loads, so the previous one isn't
// shown against the new record.
setLoading(true);
setDocument(null);
let cancelled = false;
const load = async () => {
const { documents } = await new CoreApiClient().query({
documents: {
__args: { filter: { id: { eq: recordId } }, first: 1 },
edges: {
node: { id: true, name: true, content: true, file: { url: true } },
},
},
});
if (cancelled) return;
const node = documents?.edges?.[0]?.node;
setDocument({
name: node?.name ?? 'Document',
content: node?.content ?? '',
pdfUrl: node?.file?.[0]?.url ?? undefined,
});
};
load().finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [recordId]);
if (loading || !document) {
return <div style={styles.empty}>{loading ? 'Loading…' : 'Open a document to preview it here.'}</div>;
}
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
return (
<div style={styles.scroll}>
<div style={styles.actions}>
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
Open web page
</a>
{document.pdfUrl ? (
<a style={styles.actionLink} href={document.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',
description: 'Renders a generated document as a styled, printable preview.',
component: DocumentViewer,
});
@@ -0,0 +1,353 @@
import {
type CSSProperties,
type SyntheticEvent,
useCallback,
useEffect,
useState,
} from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
closeSidePanel,
enqueueSnackbar,
unmountFrontComponent,
useSelectedRecordIds,
} from 'twenty-sdk/front-component';
import { GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
// Theme tokens are inlined as CSS-variable values: the SDK mocks the UI package
// during manifest extraction, so importing them at module level would be undefined.
const theme = {
spacing2: 'var(--t-spacing-2)',
spacing3: 'var(--t-spacing-3)',
spacing4: 'var(--t-spacing-4)',
spacing8: 'var(--t-spacing-8)',
bgPrimary: 'var(--t-background-primary)',
bgSecondary: 'var(--t-background-secondary)',
borderMedium: 'var(--t-border-color-medium)',
borderLight: 'var(--t-border-color-light)',
radiusSm: 'var(--t-border-radius-sm)',
fontPrimary: 'var(--t-font-color-primary)',
fontSecondary: 'var(--t-font-color-secondary)',
fontTertiary: 'var(--t-font-color-tertiary)',
fontInverted: 'var(--t-font-color-inverted)',
fontFamily: 'var(--t-font-family)',
sizeXs: 'var(--t-font-size-xs)',
sizeSm: 'var(--t-font-size-sm)',
sizeMd: 'var(--t-font-size-md)',
weightMedium: 'var(--t-font-weight-medium)',
weightSemiBold: 'var(--t-font-weight-semi-bold)',
blue: 'var(--t-color-blue)',
accent: 'var(--t-accent-accent4060)',
};
type Template = { id: string; name: string };
type GenerateResponse = {
success: boolean;
message?: string;
documentId?: string;
missingTokens?: string[];
error?: string;
};
const readValue = (event: SyntheticEvent<HTMLElement>): string | undefined => {
const object = event as {
detail?: { value?: string };
target?: { value?: string };
};
return object.detail?.value ?? object.target?.value;
};
const callAppRoute = async <TResponse,>(
path: string,
method: 'GET' | 'POST',
body?: Record<string, unknown>,
): Promise<TResponse> => {
const apiBaseUrl = process.env.TWENTY_API_URL;
const token =
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
if (!apiBaseUrl || !token) {
throw new Error('App is missing API URL or access token configuration.');
}
const response = await fetch(`${apiBaseUrl}/s${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
...(body ? { body: JSON.stringify(body) } : {}),
});
if (!response.ok) {
const errorBody = (await response.json().catch(() => null)) as {
message?: string;
} | null;
throw new Error(
errorBody?.message ?? `Request failed with status ${response.status}.`,
);
}
return response.json() as Promise<TResponse>;
};
const styles: Record<string, CSSProperties> = {
container: {
fontFamily: theme.fontFamily,
fontSize: theme.sizeSm,
color: theme.fontPrimary,
background: theme.bgPrimary,
display: 'flex',
flexDirection: 'column',
height: '100%',
boxSizing: 'border-box',
},
header: {
padding: theme.spacing4,
borderBottom: `1px solid ${theme.borderLight}`,
},
title: {
fontSize: theme.sizeMd,
fontWeight: theme.weightSemiBold,
margin: 0,
},
subtitle: {
fontSize: theme.sizeSm,
color: theme.fontTertiary,
margin: `${theme.spacing2} 0 0`,
},
body: {
flex: 1,
padding: theme.spacing4,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing2,
},
label: {
fontSize: theme.sizeXs,
fontWeight: theme.weightMedium,
color: theme.fontSecondary,
},
select: {
appearance: 'none',
WebkitAppearance: 'none',
background: theme.bgSecondary,
border: `1px solid ${theme.borderMedium}`,
borderRadius: theme.radiusSm,
padding: `${theme.spacing2} ${theme.spacing3}`,
color: theme.fontPrimary,
fontSize: theme.sizeSm,
fontFamily: theme.fontFamily,
height: theme.spacing8,
width: '100%',
boxSizing: 'border-box',
cursor: 'pointer',
},
footer: {
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing2,
padding: theme.spacing3,
borderTop: `1px solid ${theme.borderLight}`,
},
button: {
height: theme.spacing8,
padding: `0 ${theme.spacing3}`,
borderRadius: theme.radiusSm,
fontSize: theme.sizeSm,
fontFamily: theme.fontFamily,
fontWeight: theme.weightMedium,
cursor: 'pointer',
border: '1px solid transparent',
},
secondary: {
background: theme.bgSecondary,
color: theme.fontSecondary,
border: `1px solid ${theme.borderMedium}`,
},
primary: { background: theme.blue, color: theme.fontInverted },
primaryDisabled: { background: theme.accent, cursor: 'not-allowed' },
helper: { fontSize: theme.sizeXs, color: theme.fontTertiary },
};
const GenerateDocumentForm = () => {
const selectedRecordIds = useSelectedRecordIds();
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
const [templates, setTemplates] = useState<Template[]>([]);
const [templateId, setTemplateId] = useState('');
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const loadTemplates = useCallback(async () => {
setLoading(true);
try {
const client = new CoreApiClient();
const { documentTemplates } = await client.query({
documentTemplates: {
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
edges: { node: { id: true, name: true } },
},
});
const list: Template[] =
documentTemplates?.edges?.map(
(edge: { node: { id: string; name?: string | null } }) => ({
id: edge.node.id,
name: edge.node.name ?? 'Untitled template',
}),
) ?? [];
setTemplates(list);
if (list.length > 0) {
setTemplateId(list[0].id);
}
} catch {
await enqueueSnackbar({
message: 'Failed to load templates.',
variant: 'error',
});
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadTemplates();
}, [loadTemplates]);
const handleClose = () => {
unmountFrontComponent();
closeSidePanel();
};
const handleGenerate = async () => {
if (!templateId || !recordId) {
return;
}
setSubmitting(true);
try {
const result = await callAppRoute<GenerateResponse>(
'/documents/generate',
'POST',
{ templateId, recordId },
);
if (!result.success) {
await enqueueSnackbar({
message: result.message ?? result.error ?? 'Generation failed.',
variant: 'error',
});
return;
}
const missing = result.missingTokens?.length
? ` (${result.missingTokens.length} placeholder(s) had no value)`
: '';
await enqueueSnackbar({
message: `Document generated${missing}. Find it in the Documents view.`,
variant: 'success',
});
handleClose();
} catch (error) {
await enqueueSnackbar({
message:
error instanceof Error ? error.message : 'Generation failed.',
variant: 'error',
});
} finally {
setSubmitting(false);
}
};
const canSubmit =
templateId !== '' && recordId !== null && !submitting && !loading;
return (
<div style={styles.container}>
<div style={styles.header}>
<h2 style={styles.title}>Generate document</h2>
<p style={styles.subtitle}>
Pick a template; it will be filled with this person's data.
</p>
</div>
<div style={styles.body}>
<label htmlFor="template-select" style={styles.label}>
Template
</label>
<select
id="template-select"
value={templateId}
onChange={(event) => {
const value = readValue(event);
if (typeof value === 'string') {
setTemplateId(value);
}
}}
style={styles.select}
disabled={loading || templates.length === 0}
>
{templates.length === 0 ? (
<option value="">No person templates yet</option>
) : (
templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))
)}
</select>
{recordId === null && (
<span style={styles.helper}>
Select a single person to generate a document.
</span>
)}
</div>
<div style={styles.footer}>
<button
type="button"
style={{ ...styles.button, ...styles.secondary }}
onClick={handleClose}
disabled={submitting}
>
Cancel
</button>
<button
type="button"
style={{
...styles.button,
...styles.primary,
...(canSubmit ? {} : styles.primaryDisabled),
}}
onClick={handleGenerate}
disabled={!canSubmit}
>
{submitting ? 'Generating' : 'Generate'}
</button>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier:
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'generate-document-form',
description: 'Form to generate a document from a template for a person.',
component: GenerateDocumentForm,
});