Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
380ba2f22d | ||
|
|
e5e48674f8 | ||
|
|
e5a356b4da | ||
|
|
68a508a353 | ||
|
|
be89ef30cd | ||
|
|
d90d2e3151 | ||
|
|
e9aa6f47e5 | ||
|
|
dc00701448 | ||
|
|
a1b1622d94 | ||
|
|
c107d804d2 | ||
|
|
d16b94bde6 | ||
|
|
e95adcc757 | ||
|
|
9d613dc19d | ||
|
|
23fb2a0d58 | ||
|
|
fa04e7077e | ||
|
|
1a0588d233 | ||
|
|
8ef32c4781 | ||
|
|
7bde8a4dfa | ||
|
|
d5a7dec117 | ||
|
|
96c5728ed0 | ||
|
|
bdaff0b7e2 | ||
|
|
9a306ddb9a | ||
|
|
246afe0f2a | ||
|
|
d69e4d7008 | ||
|
|
03a2abb305 | ||
|
|
c01fc3bafb | ||
|
|
908aefe7c1 | ||
|
|
50a0bef0e4 | ||
|
|
cd651f57cb | ||
|
|
fc9723949b | ||
|
|
d389a4341d | ||
|
|
2a1d22d870 | ||
|
|
732aea9121 | ||
|
|
42269cc45b | ||
|
|
1be0f1554f | ||
|
|
d149c2a041 | ||
|
|
9698996771 | ||
|
|
9cb21e71fa | ||
|
|
a8625d8bfb | ||
|
|
3d49c21b51 | ||
|
|
7c8f060b08 | ||
|
|
89300564ba | ||
|
|
e7434bdc39 | ||
|
|
9a850e2241 | ||
|
|
91793ef930 | ||
|
|
217adc8d17 | ||
|
|
698c15b8cd | ||
|
|
34b3158308 | ||
|
|
66be7c3ef4 | ||
|
|
0a6b514898 | ||
|
|
ffb02a6878 | ||
|
|
3cbd9f2b5d | ||
|
|
cee4cf6452 | ||
|
|
cd594ce8bd | ||
|
|
26139ee463 | ||
|
|
8005b35b56 | ||
|
|
02bfebccc2 | ||
|
|
b86e6189c0 | ||
|
|
a9f8a7e1fa | ||
|
|
5526d2e5d0 | ||
|
|
0d27a255a9 | ||
|
|
ef2a113a16 | ||
|
|
e0e25eac2b | ||
|
|
8ab8f80687 | ||
|
|
a370a26b79 | ||
|
|
2f095c8903 | ||
|
|
394a3cef15 | ||
|
|
189c564840 | ||
|
|
6d4d1a97bc |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git stash:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
.git
|
||||
.env
|
||||
node_modules
|
||||
**/node_modules
|
||||
.nx/cache
|
||||
packages/twenty-server/.env
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
name: AI Catalog Sync
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *' # Daily at 6 AM UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sync-catalog:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build dependencies
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Build twenty-server
|
||||
run: npx nx build twenty-server
|
||||
|
||||
- name: Run catalog sync
|
||||
run: npx nx run twenty-server:command-no-deps ai:sync-models-dev
|
||||
|
||||
- name: Check for changes
|
||||
id: changes
|
||||
run: |
|
||||
if git diff --quiet packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-providers.json; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: 'chore: sync AI model catalog from models.dev'
|
||||
title: 'chore: sync AI model catalog from models.dev'
|
||||
body: |
|
||||
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev).
|
||||
|
||||
This PR updates pricing, context windows, and model availability based on the latest data.
|
||||
New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically.
|
||||
Deprecated models are detected based on cost-efficiency within the same model family.
|
||||
|
||||
**Please review before merging** — verify no critical models were incorrectly deprecated.
|
||||
branch: chore/ai-catalog-sync
|
||||
base: main
|
||||
labels: ai, automated
|
||||
delete-branch: true
|
||||
@@ -10,8 +10,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'merge_group' && github.event.merge_group.base_ref || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'merge_group' }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
e2e-test:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"mcpServers": {
|
||||
"postgres": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-postgres", "${PG_DATABASE_URL}"],
|
||||
"command": "bash",
|
||||
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
|
||||
"env": {}
|
||||
},
|
||||
"playwright": {
|
||||
|
||||
@@ -80,6 +80,17 @@ npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/mi
|
||||
npx nx run twenty-server:command workspace:sync-metadata
|
||||
```
|
||||
|
||||
### Database Inspection (Postgres MCP)
|
||||
|
||||
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
|
||||
- Inspect workspace data, metadata, and object definitions while developing
|
||||
- Verify migration results (columns, types, constraints) after running migrations
|
||||
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
|
||||
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
|
||||
- Inspect metadata tables to debug GraphQL schema generation or `workspace:sync-metadata` issues
|
||||
|
||||
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
|
||||
|
||||
### GraphQL
|
||||
```bash
|
||||
# Generate GraphQL types (run after schema changes)
|
||||
|
||||
@@ -25,25 +25,25 @@ See Twenty application documentation https://docs.twenty.com/developers/extend/c
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 24+ (recommended) and Yarn 4
|
||||
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
|
||||
- Docker (for the local Twenty dev server)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Scaffold a new app — the CLI will offer to start a local Twenty server
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Get help and list all available commands
|
||||
yarn twenty help
|
||||
# The scaffolder can automatically:
|
||||
# 1. Start a local Twenty server (Docker)
|
||||
# 2. Open the browser to log in (tim@apple.dev / tim@apple.dev)
|
||||
# 3. Authenticate your app via OAuth
|
||||
|
||||
# Authenticate with your Twenty server
|
||||
yarn twenty remote add --local
|
||||
|
||||
# Add a new entity to your application (guided)
|
||||
yarn twenty add
|
||||
# Or do it manually:
|
||||
yarn twenty server start # Start local Twenty server
|
||||
yarn twenty remote add --local # Authenticate via OAuth
|
||||
|
||||
# Start dev mode: watches, builds, and syncs local changes to your workspace
|
||||
# (also auto-generates typed CoreApiClient — MetadataApiClient ships pre-built with the SDK — both available via `twenty-sdk/clients`)
|
||||
yarn twenty dev
|
||||
|
||||
# Watch your application's function logs
|
||||
@@ -107,10 +107,24 @@ npx create-twenty-app@latest my-app -m
|
||||
- `skills/example-skill.ts` — Example AI agent skill definition
|
||||
- `__tests__/app-install.integration-test.ts` — Integration test that builds, installs, and verifies the app (includes `vitest.config.ts`, `tsconfig.spec.json`, and a setup file)
|
||||
|
||||
## Local server
|
||||
|
||||
The scaffolder can start a local Twenty dev server for you (all-in-one Docker image with PostgreSQL, Redis, server, and worker). You can also manage it manually:
|
||||
|
||||
```bash
|
||||
yarn twenty server start # Start (pulls image if needed)
|
||||
yarn twenty server status # Check if it's healthy
|
||||
yarn twenty server logs # Stream logs
|
||||
yarn twenty server stop # Stop (data is preserved)
|
||||
yarn twenty server reset # Wipe all data and start fresh
|
||||
```
|
||||
|
||||
The server is pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`).
|
||||
|
||||
## Next steps
|
||||
|
||||
- Run `yarn twenty help` to see all available commands.
|
||||
- Use `yarn twenty remote add --local` to authenticate with your Twenty workspace.
|
||||
- Use `yarn twenty remote add --local` to authenticate with your Twenty workspace via OAuth.
|
||||
- Explore the generated project and add your first entity with `yarn twenty add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
|
||||
- Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
|
||||
- `CoreApiClient` (for workspace data via `/graphql`) is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/clients'`.
|
||||
@@ -153,8 +167,9 @@ Our team reviews contributions for quality, security, and reusability before mer
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Auth prompts not appearing: run `yarn twenty remote add --local` again and verify the API key permissions.
|
||||
- Types not generated: ensure `yarn twenty dev` is running — it auto‑generates the typed client.
|
||||
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
|
||||
- Auth not working: make sure you're logged in to Twenty in the browser first, then run `yarn twenty remote add --local`.
|
||||
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.8.0-canary.0",
|
||||
"version": "0.8.0-canary.1",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -1,60 +1,11 @@
|
||||
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
Start development mode — it auto-connects to your local Twenty server at localhost:3000:
|
||||
|
||||
```bash
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
Open your Twenty instance and go to `/settings/applications` section to see the result.
|
||||
|
||||
## Available Commands
|
||||
|
||||
Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & authentication
|
||||
yarn twenty remote add --local # Connect to local Twenty server
|
||||
yarn twenty remote add <url> # Connect to a remote server (OAuth)
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote switch # Switch default remote
|
||||
yarn twenty remote status # Check auth status
|
||||
yarn twenty remote remove <name> # Remove a remote
|
||||
|
||||
# Development
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty build # Build the application
|
||||
yarn twenty deploy # Deploy to a Twenty server
|
||||
yarn twenty publish # Publish to npm
|
||||
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty exec # Execute a function with JSON payload
|
||||
yarn twenty logs # Stream function logs
|
||||
yarn twenty uninstall # Uninstall app from server
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
If your project includes the example integration test (`src/__tests__/app-install.integration-test.ts`), you can run it with:
|
||||
|
||||
```bash
|
||||
# Make sure a Twenty server is running at http://localhost:3000
|
||||
yarn test
|
||||
```
|
||||
|
||||
The test builds and installs the app, then verifies it appears in the applications list. Test configuration (API URL and API key) is defined in `vitest.config.ts`.
|
||||
|
||||
## LLMs instructions
|
||||
|
||||
Main docs and pitfalls are available in LLMS.md file.
|
||||
Run `yarn twenty help` to list all available commands.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Twenty applications, take a look at the following resources:
|
||||
|
||||
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
|
||||
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
|
||||
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/capabilities/apps)
|
||||
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
|
||||
- [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
@@ -46,6 +46,7 @@ export class CreateAppCommand {
|
||||
|
||||
await fs.ensureDir(appDirectory);
|
||||
|
||||
console.log(chalk.gray(' Scaffolding project files...'));
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
@@ -54,29 +55,20 @@ export class CreateAppCommand {
|
||||
exampleOptions,
|
||||
});
|
||||
|
||||
console.log(chalk.gray(' Installing dependencies...'));
|
||||
await install(appDirectory);
|
||||
|
||||
console.log(chalk.gray(' Initializing git repository...'));
|
||||
await tryGitInit(appDirectory);
|
||||
|
||||
let localResult: LocalInstanceResult = { running: false };
|
||||
|
||||
if (!options.skipLocalInstance) {
|
||||
const { needsLocalInstance } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'needsLocalInstance',
|
||||
message:
|
||||
'Do you need a local instance of Twenty? Recommended if you not have one already.',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
// Auto-detect a running server first
|
||||
localResult = await setupLocalInstance(appDirectory);
|
||||
|
||||
if (needsLocalInstance) {
|
||||
localResult = await setupLocalInstance();
|
||||
}
|
||||
|
||||
if (isDefined(localResult.apiKey)) {
|
||||
this.runAuthLogin(appDirectory, localResult.apiKey);
|
||||
if (localResult.running && localResult.serverUrl) {
|
||||
await this.connectToLocal(appDirectory, localResult.serverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,23 +193,29 @@ export class CreateAppCommand {
|
||||
appDirectory: string;
|
||||
appName: string;
|
||||
}): void {
|
||||
console.log(chalk.blue('🎯 Creating Twenty Application'));
|
||||
console.log(chalk.gray(`📁 Directory: ${appDirectory}`));
|
||||
console.log(chalk.gray(`📝 Name: ${appName}`));
|
||||
console.log(chalk.blue('Creating Twenty Application'));
|
||||
console.log(chalk.gray(` Directory: ${appDirectory}`));
|
||||
console.log(chalk.gray(` Name: ${appName}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
private runAuthLogin(appDirectory: string, apiKey: string): void {
|
||||
private async connectToLocal(
|
||||
appDirectory: string,
|
||||
serverUrl: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
execSync(
|
||||
`yarn twenty auth:login --api-key "${apiKey}" --api-url http://localhost:3000`,
|
||||
{ cwd: appDirectory, stdio: 'inherit' },
|
||||
`npx nx run twenty-sdk:start -- remote add ${serverUrl} --as local`,
|
||||
{
|
||||
cwd: appDirectory,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
console.log(chalk.green('✅ Authenticated with local Twenty instance.'));
|
||||
console.log(chalk.green('Authenticated with local Twenty instance.'));
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Auto auth:login failed. Run `yarn twenty auth:login` manually.',
|
||||
'Authentication skipped. Run `npx nx run twenty-sdk:start -- remote add --local` manually.',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -229,27 +227,21 @@ export class CreateAppCommand {
|
||||
): void {
|
||||
const dirName = appDirectory.split('/').reverse()[0] ?? '';
|
||||
|
||||
console.log(chalk.green('✅ Application created!'));
|
||||
console.log(chalk.green('Application created!'));
|
||||
console.log('');
|
||||
console.log(chalk.blue('Next steps:'));
|
||||
console.log(chalk.gray(` cd ${dirName}`));
|
||||
|
||||
if (localResult.apiKey) {
|
||||
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
|
||||
} else if (localResult.running) {
|
||||
if (!localResult.running) {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
' yarn twenty remote add --local # Authenticate with Twenty',
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
|
||||
} else {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
' yarn twenty remote add --local # Authenticate with Twenty',
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.gray(' yarn twenty dev # Start dev mode'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,194 +1,101 @@
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { platform } from 'node:os';
|
||||
|
||||
const INSTALL_SCRIPT_URL =
|
||||
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/scripts/install.sh';
|
||||
const DEFAULT_PORT = 2020;
|
||||
|
||||
const SERVER_CONTAINER = 'twenty-server-1';
|
||||
const DB_CONTAINER = 'twenty-db-1';
|
||||
// Minimal health check — the full implementation lives in twenty-sdk
|
||||
const isServerReady = async (port: number): Promise<boolean> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const isDockerAvailable = (): boolean => {
|
||||
try {
|
||||
execSync('docker compose version', { stdio: 'ignore' });
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isDockerRunning = (): boolean => {
|
||||
try {
|
||||
execSync('docker info', { stdio: 'ignore' });
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isTwentyServerRunning = async (): Promise<boolean> => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch('http://localhost:3000/healthz', {
|
||||
const response = await fetch(`http://localhost:${port}/healthz`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
return body.status === 'ok';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveWorkspaceId = (): string | null => {
|
||||
try {
|
||||
const result = execSync(
|
||||
`docker exec ${DB_CONTAINER} psql -U postgres -d default -t -c "SELECT id FROM core.workspace WHERE \\"activationStatus\\" = 'ACTIVE' LIMIT 1"`,
|
||||
{ encoding: 'utf-8' },
|
||||
).trim();
|
||||
|
||||
return result || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const generateApiKeyToken = (workspaceId: string): string | null => {
|
||||
try {
|
||||
const output = execSync(
|
||||
`docker exec -e NODE_ENV=development ${SERVER_CONTAINER} yarn command:prod workspace:generate-api-key -w ${workspaceId}`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
|
||||
const TOKEN_PREFIX = 'TOKEN:';
|
||||
const tokenLine = output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.find((line) => line.includes(TOKEN_PREFIX));
|
||||
|
||||
if (!tokenLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenStartIndex =
|
||||
tokenLine.indexOf(TOKEN_PREFIX) + TOKEN_PREFIX.length;
|
||||
|
||||
return tokenLine.slice(tokenStartIndex).trim();
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
export type LocalInstanceResult = {
|
||||
running: boolean;
|
||||
apiKey?: string;
|
||||
serverUrl?: string;
|
||||
};
|
||||
|
||||
export const setupLocalInstance = async (): Promise<LocalInstanceResult> => {
|
||||
export const setupLocalInstance = async (
|
||||
appDirectory: string,
|
||||
): Promise<LocalInstanceResult> => {
|
||||
console.log('');
|
||||
console.log(chalk.blue('🐳 Setting up local Twenty instance...'));
|
||||
console.log(chalk.blue('Setting up local Twenty instance...'));
|
||||
|
||||
if (await isTwentyServerRunning()) {
|
||||
console.log(
|
||||
chalk.green('✅ Twenty server is already running on localhost:3000.'),
|
||||
);
|
||||
} else {
|
||||
if (!isDockerAvailable()) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Docker Compose is not installed. Please install Docker first.',
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(' See https://docs.docker.com/get-docker/'));
|
||||
if (await isServerReady(DEFAULT_PORT)) {
|
||||
const serverUrl = `http://localhost:${DEFAULT_PORT}`;
|
||||
|
||||
return { running: false };
|
||||
}
|
||||
console.log(chalk.green(`Twenty server detected on ${serverUrl}.`));
|
||||
|
||||
if (!isDockerRunning()) {
|
||||
return { running: true, serverUrl };
|
||||
}
|
||||
|
||||
// Delegate to `twenty server start` from the scaffolded app
|
||||
console.log(chalk.gray('Starting local Twenty server...'));
|
||||
|
||||
try {
|
||||
execSync('yarn twenty server start', {
|
||||
cwd: appDirectory,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Failed to start Twenty server. Run `yarn twenty server start` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: false };
|
||||
}
|
||||
|
||||
console.log(chalk.gray('Waiting for Twenty to be ready...'));
|
||||
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = 180 * 1000;
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (await isServerReady(DEFAULT_PORT)) {
|
||||
const serverUrl = `http://localhost:${DEFAULT_PORT}`;
|
||||
|
||||
console.log(chalk.green(`Twenty server is running on ${serverUrl}.`));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Docker is not running. Please start Docker and try again.',
|
||||
chalk.gray(
|
||||
'Workspace ready — login with tim@apple.dev / tim@apple.dev',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: false };
|
||||
}
|
||||
const openCommand = platform() === 'darwin' ? 'open' : 'xdg-open';
|
||||
|
||||
try {
|
||||
execSync(`bash <(curl -sL ${INSTALL_SCRIPT_URL})`, {
|
||||
stdio: 'inherit',
|
||||
shell: '/bin/bash',
|
||||
});
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow('⚠️ Local instance setup did not complete successfully.'),
|
||||
);
|
||||
try {
|
||||
execSync(`${openCommand} ${serverUrl}`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Ignore if browser can't be opened
|
||||
}
|
||||
|
||||
return { running: false };
|
||||
return { running: true, serverUrl };
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(
|
||||
chalk.blue(
|
||||
'👉 Please create your workspace in the browser before continuing.',
|
||||
chalk.yellow(
|
||||
'Twenty server did not become healthy in time. Check: yarn twenty server logs',
|
||||
),
|
||||
);
|
||||
|
||||
const { workspaceCreated } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'workspaceCreated',
|
||||
message: 'Have you finished creating your workspace?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!workspaceCreated) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Skipping API key generation. Run `yarn twenty remote add --local` manually after creating your workspace.',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: true };
|
||||
}
|
||||
|
||||
console.log(chalk.blue('🔑 Generating API key for your workspace...'));
|
||||
|
||||
const workspaceId = getActiveWorkspaceId();
|
||||
|
||||
if (!isDefined(workspaceId)) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ No active workspace found. Make sure you completed the signup flow, then run `yarn twenty auth:login` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: true };
|
||||
}
|
||||
|
||||
const apiKey = generateApiKeyToken(workspaceId);
|
||||
|
||||
if (!isDefined(apiKey)) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Could not generate API key. Run `yarn twenty auth:login` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: true };
|
||||
}
|
||||
|
||||
console.log(chalk.green('✅ API key generated for your workspace.'));
|
||||
|
||||
return { running: true, apiKey };
|
||||
return { running: false };
|
||||
};
|
||||
|
||||
@@ -93,7 +93,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { beforeAll } from 'vitest';
|
||||
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
|
||||
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
|
||||
|
||||
const assertServerIsReachable = async () => {
|
||||
|
||||
@@ -89,6 +89,15 @@ password
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Check if using external secret for redis password */}}
|
||||
{{- define "twenty.redis.useExternalSecret" -}}
|
||||
{{- if and (not .Values.redisInternal.enabled) .Values.redis.external.secretName .Values.redis.external.passwordKey -}}
|
||||
true
|
||||
{{- else -}}
|
||||
false
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Compose Redis URL */}}
|
||||
{{- define "twenty.redisUrl" -}}
|
||||
{{- if .Values.server.env.REDIS_URL -}}
|
||||
@@ -99,9 +108,14 @@ password
|
||||
{{- else -}}
|
||||
{{- $host := .Values.redis.external.host | default "redis" -}}
|
||||
{{- $port := .Values.redis.external.port | default 6379 -}}
|
||||
{{- if or (eq (include "twenty.redis.useExternalSecret" .) "true") (.Values.redis.external.password) -}}
|
||||
{{- $auth := ":$(REDIS_PASSWORD)@" -}}
|
||||
{{- printf "redis://%s%s:%v" $auth $host $port -}}
|
||||
{{- else -}}
|
||||
{{- printf "redis://%s:%v" $host $port -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Compose Server URL from override, ingress, or service */}}
|
||||
{{- define "twenty.serverUrl" -}}
|
||||
|
||||
@@ -46,12 +46,12 @@ spec:
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: redis-data
|
||||
{{- if .Values.redisInternal.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.redisInternal.persistence.existingClaim | default (printf "%s-redis" (include "twenty.fullname" .)) }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: redis-data
|
||||
{{- if .Values.redisInternal.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.redisInternal.persistence.existingClaim | default (printf "%s-redis" (include "twenty.fullname" .)) }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -83,16 +83,16 @@ spec:
|
||||
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -Atc "SELECT 1 FROM pg_database WHERE datname = :'db'" | grep -q 1 || \
|
||||
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -c 'CREATE DATABASE :"db";'
|
||||
echo "Creating app user ${APP_USER} if it doesn't exist..."
|
||||
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v app_user="${APP_USER}" -v app_password="${APP_PASSWORD}" <<'EOSQL'
|
||||
DO
|
||||
$do$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_user') THEN
|
||||
EXECUTE format('CREATE USER %I WITH PASSWORD %L', :'app_user', :'app_password');
|
||||
END IF;
|
||||
END
|
||||
$do$;
|
||||
EOSQL
|
||||
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v app_user="${APP_USER}" -v app_password="${APP_PASSWORD}" <<'EOSQL'
|
||||
DO
|
||||
$do$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_user') THEN
|
||||
EXECUTE format('CREATE USER %I WITH PASSWORD %L', :'app_user', :'app_password');
|
||||
END IF;
|
||||
END
|
||||
$do$;
|
||||
EOSQL
|
||||
echo "Creating core schema and granting permissions..."
|
||||
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'CREATE SCHEMA IF NOT EXISTS core'
|
||||
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v db="${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON DATABASE :"db" TO :"app_user";'
|
||||
@@ -106,31 +106,6 @@ spec:
|
||||
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO :"app_user";'
|
||||
echo "Database ${DBNAME} is ready."
|
||||
{{- end }}
|
||||
- name: run-migrations
|
||||
{{- $img := include "twenty.server.image" . }}
|
||||
image: {{ include "twenty.image.repository" $img }}:{{ include "twenty.image.tag" $img }}
|
||||
imagePullPolicy: {{ include "twenty.image.pullPolicy" $img }}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
npx -y typeorm migration:run -d dist/database/typeorm/core/core.datasource
|
||||
env:
|
||||
{{- if eq (include "twenty.db.useExternalSecret" .) "true" }}
|
||||
- name: DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "twenty.dbPassword.secretName" . }}
|
||||
key: {{ include "twenty.dbPassword.secretKey" . }}
|
||||
- name: PG_DATABASE_URL
|
||||
value: {{ include "twenty.dbUrl.template" . | quote }}
|
||||
{{- else }}
|
||||
- name: PG_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "twenty.dbUrl.secretName" . }}
|
||||
key: url
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: server
|
||||
{{- $img := include "twenty.server.image" . }}
|
||||
@@ -154,6 +129,16 @@ spec:
|
||||
name: {{ include "twenty.dbUrl.secretName" . }}
|
||||
key: url
|
||||
{{- end }}
|
||||
{{- if eq (include "twenty.redis.useExternalSecret" .) "true" }}
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.redis.external.secretName }}
|
||||
key: {{ .Values.redis.external.passwordKey }}
|
||||
{{- else if .Values.redis.external.password }}
|
||||
- name: REDIS_PASSWORD
|
||||
value: {{ .Values.redis.external.password | quote }}
|
||||
{{- end }}
|
||||
- name: REDIS_URL
|
||||
value: {{ include "twenty.redisUrl" . | quote }}
|
||||
- name: SIGN_IN_PREFILLED
|
||||
@@ -171,7 +156,10 @@ spec:
|
||||
key: accessToken
|
||||
{{- $storageEnv := (include "twenty.storageEnv" .) }}
|
||||
{{- if $storageEnv }}
|
||||
{{ $storageEnv | nindent 12 }}
|
||||
{{- $storageEnv | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.server.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http-tcp
|
||||
|
||||
@@ -67,6 +67,16 @@ spec:
|
||||
name: {{ include "twenty.dbUrl.secretName" . }}
|
||||
key: url
|
||||
{{- end }}
|
||||
{{- if eq (include "twenty.redis.useExternalSecret" .) "true" }}
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.redis.external.secretName }}
|
||||
key: {{ .Values.redis.external.passwordKey }}
|
||||
{{- else if .Values.redis.external.password }}
|
||||
- name: REDIS_PASSWORD
|
||||
value: {{ .Values.redis.external.password | quote }}
|
||||
{{- end }}
|
||||
- name: REDIS_URL
|
||||
value: {{ include "twenty.redisUrl" . | quote }}
|
||||
- name: STORAGE_TYPE
|
||||
@@ -76,9 +86,12 @@ spec:
|
||||
secretKeyRef:
|
||||
name: {{ include "twenty.secret.tokens.name" . }}
|
||||
key: accessToken
|
||||
{{- with .Values.worker.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- $storageEnv := (include "twenty.storageEnv" .) }}
|
||||
{{- if $storageEnv }}
|
||||
{{ $storageEnv | nindent 12 }}
|
||||
{{- $storageEnv | nindent 12 }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.worker.resources | nindent 12 }}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{{- if and .Values.redisInternal.enabled .Values.redisInternal.persistence.enabled (not .Values.redisInternal.persistence.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "twenty.fullname" . }}-redis
|
||||
namespace: {{ include "twenty.namespace" . }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "twenty.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
accessModes:
|
||||
{{ toYaml .Values.redisInternal.persistence.accessModes | nindent 4 }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.redisInternal.persistence.size }}
|
||||
{{- if .Values.redisInternal.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.redisInternal.persistence.storageClass }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,58 @@
|
||||
suite: env mapping
|
||||
templates:
|
||||
- templates/deployment-server.yaml
|
||||
- templates/deployment-worker.yaml
|
||||
release:
|
||||
name: my-twenty
|
||||
namespace: default
|
||||
tests:
|
||||
- it: renders server extraEnv with plain value
|
||||
template: templates/deployment-server.yaml
|
||||
set:
|
||||
server.extraEnv:
|
||||
- name: FEATURE_X_ENABLED
|
||||
value: "true"
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: FEATURE_X_ENABLED
|
||||
value: "true"
|
||||
|
||||
- it: renders server extraEnv with valueFrom
|
||||
template: templates/deployment-server.yaml
|
||||
set:
|
||||
server.extraEnv:
|
||||
- name: SMTP_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: smtp-creds
|
||||
key: password
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: SMTP_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: smtp-creds
|
||||
key: password
|
||||
|
||||
- it: renders worker extraEnv with valueFrom
|
||||
template: templates/deployment-worker.yaml
|
||||
set:
|
||||
worker.extraEnv:
|
||||
- name: CUSTOM_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: my-secret
|
||||
key: custom
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CUSTOM_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: my-secret
|
||||
key: custom
|
||||
@@ -0,0 +1,89 @@
|
||||
suite: redis external authentication
|
||||
templates:
|
||||
- templates/deployment-server.yaml
|
||||
- templates/deployment-worker.yaml
|
||||
release:
|
||||
name: my-twenty
|
||||
namespace: default
|
||||
tests:
|
||||
- it: injects REDIS_PASSWORD from external secret into server
|
||||
template: templates/deployment-server.yaml
|
||||
set:
|
||||
redisInternal.enabled: false
|
||||
redis.external.host: redis.example.com
|
||||
redis.external.secretName: redis-creds
|
||||
redis.external.passwordKey: password
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: redis-creds
|
||||
key: password
|
||||
|
||||
- it: injects REDIS_PASSWORD from external secret into worker
|
||||
template: templates/deployment-worker.yaml
|
||||
set:
|
||||
redisInternal.enabled: false
|
||||
redis.external.host: redis.example.com
|
||||
redis.external.secretName: redis-creds
|
||||
redis.external.passwordKey: password
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: redis-creds
|
||||
key: password
|
||||
|
||||
- it: injects plaintext REDIS_PASSWORD when password set directly in server
|
||||
template: templates/deployment-server.yaml
|
||||
set:
|
||||
redisInternal.enabled: false
|
||||
redis.external.host: redis.example.com
|
||||
redis.external.password: "s3cr3t"
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: REDIS_PASSWORD
|
||||
value: "s3cr3t"
|
||||
|
||||
- it: injects plaintext REDIS_PASSWORD when password set directly in worker
|
||||
template: templates/deployment-worker.yaml
|
||||
set:
|
||||
redisInternal.enabled: false
|
||||
redis.external.host: redis.example.com
|
||||
redis.external.password: "s3cr3t"
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: REDIS_PASSWORD
|
||||
value: "s3cr3t"
|
||||
|
||||
- it: does not inject REDIS_PASSWORD into server when using internal redis
|
||||
template: templates/deployment-server.yaml
|
||||
set:
|
||||
redisInternal.enabled: true
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: REDIS_PASSWORD
|
||||
any: true
|
||||
|
||||
- it: does not inject REDIS_PASSWORD into worker when using internal redis
|
||||
template: templates/deployment-worker.yaml
|
||||
set:
|
||||
redisInternal.enabled: true
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: REDIS_PASSWORD
|
||||
any: true
|
||||
@@ -105,18 +105,3 @@ tests:
|
||||
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
|
||||
pattern: ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES
|
||||
|
||||
# TypeORM Migration Tests
|
||||
# ======================
|
||||
# TypeORM migrations are configured to use the core.datasource which targets the
|
||||
# 'core' schema. This ensures the _typeorm_migrations table and all application
|
||||
# tables use the dedicated core schema.
|
||||
|
||||
- it: migrations run against core datasource
|
||||
template: templates/deployment-server.yaml
|
||||
set:
|
||||
db.enabled: true
|
||||
asserts:
|
||||
- matchRegex:
|
||||
path: spec.template.spec.initContainers[?(@.name=="run-migrations")].command[2]
|
||||
pattern: core\.datasource
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ tests:
|
||||
- crm.example.com
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.spec.containers[0].env[0].value
|
||||
path: spec.template.spec.containers[0].env[?(@.name=="SERVER_URL")].value
|
||||
value: "https://crm.example.com:443"
|
||||
- it: falls back to service when ingress disabled
|
||||
set:
|
||||
@@ -28,7 +28,7 @@ tests:
|
||||
server.env.SERVER_URL: ""
|
||||
asserts:
|
||||
- matchRegex:
|
||||
path: spec.template.spec.containers[0].env[0].value
|
||||
path: spec.template.spec.containers[0].env[?(@.name=="SERVER_URL")].value
|
||||
pattern: ^http://my-twenty-twenty-server\.default\.svc\.cluster\.local:3000$
|
||||
---
|
||||
suite: ingress configuration
|
||||
@@ -66,6 +66,7 @@ tests:
|
||||
set:
|
||||
server.ingress.acme: true
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.annotations[cert-manager.io/cluster-issuer]
|
||||
value: letsencrypt-prod
|
||||
- isSubset:
|
||||
path: metadata.annotations
|
||||
content:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
|
||||
@@ -66,6 +66,22 @@
|
||||
"PASSWORD_RESET_TOKEN_EXPIRES_IN": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"extraEnv": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"value": { "type": "string" },
|
||||
"valueFrom": { "type": "object" }
|
||||
},
|
||||
"required": ["name"],
|
||||
"oneOf": [
|
||||
{ "required": ["value"] },
|
||||
{ "required": ["valueFrom"] }
|
||||
]
|
||||
}
|
||||
},
|
||||
"service": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -141,6 +157,22 @@
|
||||
"STORAGE_TYPE": { "type": "string" },
|
||||
"DISABLE_DB_MIGRATIONS": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"extraEnv": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"value": { "type": "string" },
|
||||
"valueFrom": { "type": "object" }
|
||||
},
|
||||
"required": ["name"],
|
||||
"oneOf": [
|
||||
{ "required": ["value"] },
|
||||
{ "required": ["valueFrom"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -52,6 +52,14 @@ server:
|
||||
SIGN_IN_PREFILLED: "false"
|
||||
ACCESS_TOKEN_EXPIRES_IN: "7d"
|
||||
LOGIN_TOKEN_EXPIRES_IN: "1h"
|
||||
extraEnv: []
|
||||
# - name: EMAIL_DRIVER
|
||||
# value: smtp
|
||||
# - name: SMTP_PASSWORD
|
||||
# valueFrom:
|
||||
# secretKeyRef:
|
||||
# name: smtp-creds
|
||||
# key: password
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
@@ -105,6 +113,8 @@ worker:
|
||||
cpu: 1000m
|
||||
memory: 2048Mi
|
||||
|
||||
extraEnv: []
|
||||
|
||||
# PostgreSQL
|
||||
db:
|
||||
enabled: true
|
||||
@@ -174,3 +184,6 @@ redis:
|
||||
external:
|
||||
host: ""
|
||||
port: 6379
|
||||
password: ""
|
||||
secretName: ""
|
||||
passwordKey: ""
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
ARG APP_VERSION
|
||||
|
||||
# === Stage 1: Common dependencies ===
|
||||
FROM node:22-alpine AS common-deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package.json ./yarn.lock ./.yarnrc.yml ./tsconfig.base.json ./nx.json /app/
|
||||
COPY ./.yarn/releases /app/.yarn/releases
|
||||
COPY ./.yarn/patches /app/.yarn/patches
|
||||
|
||||
COPY ./packages/twenty-emails/package.json /app/packages/twenty-emails/
|
||||
COPY ./packages/twenty-server/package.json /app/packages/twenty-server/
|
||||
COPY ./packages/twenty-server/patches /app/packages/twenty-server/patches
|
||||
COPY ./packages/twenty-ui/package.json /app/packages/twenty-ui/
|
||||
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/
|
||||
COPY ./packages/twenty-front/package.json /app/packages/twenty-front/
|
||||
COPY ./packages/twenty-sdk/package.json /app/packages/twenty-sdk/
|
||||
|
||||
RUN yarn && yarn cache clean && npx nx reset
|
||||
|
||||
# === Stage 2: Build server from source ===
|
||||
FROM common-deps AS twenty-server-build
|
||||
|
||||
COPY ./packages/twenty-emails /app/packages/twenty-emails
|
||||
COPY ./packages/twenty-shared /app/packages/twenty-shared
|
||||
COPY ./packages/twenty-ui /app/packages/twenty-ui
|
||||
COPY ./packages/twenty-sdk /app/packages/twenty-sdk
|
||||
COPY ./packages/twenty-server /app/packages/twenty-server
|
||||
|
||||
RUN npx nx run twenty-server:build
|
||||
RUN yarn workspaces focus --production twenty-emails twenty-shared twenty-sdk twenty-server
|
||||
|
||||
# === Stage 3: Build frontend from source ===
|
||||
# Requires ~10GB Docker memory. To skip, pre-build on host first:
|
||||
# npx nx build twenty-front
|
||||
# The COPY below will pick up packages/twenty-front/build/ if it exists.
|
||||
FROM common-deps AS twenty-front-build
|
||||
COPY --from=twenty-server-build /app/package.json /tmp/.build-sentinel
|
||||
|
||||
COPY ./packages/twenty-front /app/packages/twenty-front
|
||||
COPY ./packages/twenty-ui /app/packages/twenty-ui
|
||||
COPY ./packages/twenty-shared /app/packages/twenty-shared
|
||||
COPY ./packages/twenty-sdk /app/packages/twenty-sdk
|
||||
RUN if [ -d /app/packages/twenty-front/build ]; then \
|
||||
echo "Using pre-built frontend from host"; \
|
||||
else \
|
||||
NODE_OPTIONS="--max-old-space-size=8192" npx nx build twenty-front; \
|
||||
fi
|
||||
|
||||
# === Stage 4: s6-overlay ===
|
||||
FROM alpine:3.20 AS s6-fetch
|
||||
ARG S6_OVERLAY_VERSION=3.2.0.2
|
||||
ARG TARGETARCH
|
||||
RUN if [ "$TARGETARCH" = "arm64" ]; then echo "aarch64" > /tmp/s6arch; \
|
||||
else echo "x86_64" > /tmp/s6arch; fi
|
||||
RUN S6_ARCH=$(cat /tmp/s6arch) && \
|
||||
wget -O /tmp/s6-overlay-noarch.tar.xz \
|
||||
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz" && \
|
||||
wget -O /tmp/s6-overlay-noarch.tar.xz.sha256 \
|
||||
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz.sha256" && \
|
||||
wget -O /tmp/s6-overlay-arch.tar.xz \
|
||||
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz" && \
|
||||
wget -O /tmp/s6-overlay-arch.tar.xz.sha256 \
|
||||
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz.sha256" && \
|
||||
cd /tmp && \
|
||||
NOARCH_SUM=$(awk '{print $1}' s6-overlay-noarch.tar.xz.sha256) && \
|
||||
ARCH_SUM=$(awk '{print $1}' s6-overlay-arch.tar.xz.sha256) && \
|
||||
echo "$NOARCH_SUM s6-overlay-noarch.tar.xz" | sha256sum -c - && \
|
||||
echo "$ARCH_SUM s6-overlay-arch.tar.xz" | sha256sum -c -
|
||||
|
||||
# === Stage 5: Final all-in-one image ===
|
||||
FROM node:22-alpine
|
||||
|
||||
# s6-overlay
|
||||
COPY --from=s6-fetch /tmp/s6-overlay-noarch.tar.xz /tmp/
|
||||
COPY --from=s6-fetch /tmp/s6-overlay-arch.tar.xz /tmp/
|
||||
RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz \
|
||||
&& tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz \
|
||||
&& rm /tmp/s6-overlay-*.tar.xz
|
||||
|
||||
# Infrastructure: Postgres, Redis, and utilities
|
||||
RUN apk add --no-cache \
|
||||
postgresql16 postgresql16-contrib \
|
||||
redis \
|
||||
curl jq su-exec
|
||||
|
||||
# tsx for database setup scripts
|
||||
RUN npm install -g tsx
|
||||
|
||||
# Twenty application (both server and frontend built from source)
|
||||
COPY --from=twenty-server-build /app /app
|
||||
COPY --from=twenty-front-build /app/packages/twenty-front/build /app/packages/twenty-server/dist/front
|
||||
|
||||
# s6 service definitions
|
||||
COPY packages/twenty-docker/twenty-app-dev/rootfs/ /
|
||||
|
||||
# Data directories
|
||||
RUN mkdir -p /data/postgres /data/redis /app/.local-storage \
|
||||
&& chown -R postgres:postgres /data/postgres \
|
||||
&& chown 1000:1000 /data/redis /app/.local-storage
|
||||
|
||||
ARG REACT_APP_SERVER_BASE_URL
|
||||
ARG APP_VERSION=0.0.0
|
||||
|
||||
# Tell s6-overlay to preserve container environment variables
|
||||
ENV S6_KEEP_ENV=1
|
||||
|
||||
# Environment defaults
|
||||
ENV PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default \
|
||||
SERVER_URL=http://localhost:2020 \
|
||||
REDIS_URL=redis://localhost:6379 \
|
||||
STORAGE_TYPE=local \
|
||||
APP_SECRET=twenty-app-dev-secret-not-for-production \
|
||||
REACT_APP_SERVER_BASE_URL=$REACT_APP_SERVER_BASE_URL \
|
||||
APP_VERSION=$APP_VERSION \
|
||||
NODE_ENV=development \
|
||||
NODE_PORT=3000 \
|
||||
DISABLE_DB_MIGRATIONS=true \
|
||||
DISABLE_CRON_JOBS_REGISTRATION=true \
|
||||
IS_BILLING_ENABLED=false \
|
||||
SIGN_IN_PREFILLED=true
|
||||
|
||||
EXPOSE 3000
|
||||
VOLUME ["/data/postgres", "/app/.local-storage"]
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty
|
||||
LABEL org.opencontainers.image.description="All-in-one Twenty image for local development and SDK usage. Includes PostgreSQL, Redis, server, and worker."
|
||||
|
||||
ENTRYPOINT ["/init"]
|
||||
@@ -0,0 +1 @@
|
||||
oneshot
|
||||
@@ -0,0 +1 @@
|
||||
/bin/sh /etc/s6-overlay/scripts/init-db.sh
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Initialize PostgreSQL data directory if empty
|
||||
if [ ! -f /data/postgres/PG_VERSION ]; then
|
||||
echo "Initializing PostgreSQL data directory..."
|
||||
su-exec postgres initdb -D /data/postgres --auth=trust --encoding=UTF8
|
||||
# Allow local connections without password
|
||||
echo "host all all 127.0.0.1/32 trust" >> /data/postgres/pg_hba.conf
|
||||
echo "host all all ::1/128 trust" >> /data/postgres/pg_hba.conf
|
||||
fi
|
||||
|
||||
exec su-exec postgres postgres -D /data/postgres \
|
||||
-c listen_addresses=localhost \
|
||||
-c unix_socket_directories=/tmp
|
||||
@@ -0,0 +1 @@
|
||||
longrun
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
exec redis-server \
|
||||
--dir /data/redis \
|
||||
--maxmemory-policy noeviction \
|
||||
--bind 127.0.0.1 \
|
||||
--protected-mode yes
|
||||
@@ -0,0 +1 @@
|
||||
longrun
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
cd /app/packages/twenty-server
|
||||
exec yarn start:prod
|
||||
@@ -0,0 +1 @@
|
||||
longrun
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
cd /app/packages/twenty-server
|
||||
exec yarn worker:prod
|
||||
@@ -0,0 +1 @@
|
||||
longrun
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Wait for PostgreSQL to be ready (timeout after 60s)
|
||||
echo "Waiting for PostgreSQL..."
|
||||
TRIES=0
|
||||
until su-exec postgres pg_isready -h localhost; do
|
||||
TRIES=$((TRIES + 1))
|
||||
if [ "$TRIES" -ge 120 ]; then
|
||||
echo "ERROR: PostgreSQL did not become ready within 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
echo "PostgreSQL is ready."
|
||||
|
||||
# Create role if it doesn't exist
|
||||
su-exec postgres psql -h localhost -tc \
|
||||
"SELECT 1 FROM pg_roles WHERE rolname='twenty'" | grep -q 1 \
|
||||
|| su-exec postgres psql -h localhost -c "CREATE ROLE twenty WITH LOGIN PASSWORD 'twenty' SUPERUSER"
|
||||
|
||||
# Create database if it doesn't exist
|
||||
su-exec postgres psql -h localhost -tc \
|
||||
"SELECT 1 FROM pg_database WHERE datname='default'" | grep -q 1 \
|
||||
|| su-exec postgres createdb -h localhost -O twenty default
|
||||
|
||||
# Run Twenty database setup and migrations
|
||||
cd /app/packages/twenty-server
|
||||
|
||||
has_schema=$(PGPASSWORD=twenty psql -h localhost -U twenty -d default -tAc \
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'core')")
|
||||
|
||||
if [ "$has_schema" = "f" ]; then
|
||||
echo "Database appears to be empty, running initial setup..."
|
||||
NODE_OPTIONS="--max-old-space-size=1500" tsx ./scripts/setup-db.ts
|
||||
fi
|
||||
|
||||
# Always run migrations (idempotent — skips already-applied ones)
|
||||
yarn database:migrate:prod
|
||||
|
||||
yarn command:prod cache:flush
|
||||
yarn command:prod upgrade
|
||||
yarn command:prod cache:flush
|
||||
|
||||
# Only seed on first boot — check if the dev workspace already exists
|
||||
has_workspace=$(PGPASSWORD=twenty psql -h localhost -U twenty -d default -tAc \
|
||||
"SELECT EXISTS (SELECT 1 FROM core.workspace WHERE id = '20202020-1c25-4d02-bf25-6aeccf7ea419')")
|
||||
|
||||
if [ "$has_workspace" = "f" ]; then
|
||||
echo "Seeding app dev data..."
|
||||
yarn command:prod workspace:seed:dev --light || true
|
||||
else
|
||||
echo "Dev workspace already seeded, skipping."
|
||||
fi
|
||||
|
||||
echo "Database initialization complete."
|
||||
@@ -20,19 +20,51 @@ Apps let you build and manage Twenty customizations **as code**. Instead of conf
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 24+ and Yarn 4
|
||||
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
|
||||
- Docker (for the local Twenty dev server)
|
||||
|
||||
## Getting Started
|
||||
|
||||
Create a new app using the official scaffolder, then authenticate and start developing:
|
||||
Create a new app using the official scaffolder. It can automatically start a local Twenty instance for you:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Scaffold a new app (includes all examples by default)
|
||||
# Scaffold a new app — the CLI will offer to start a local Twenty server
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Start dev mode: automatically syncs local changes to your workspace
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Local Server Management
|
||||
|
||||
The SDK includes commands to manage a local Twenty dev server (all-in-one Docker image with PostgreSQL, Redis, server, and worker):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Start the local server (pulls the image if needed)
|
||||
yarn twenty server start
|
||||
|
||||
# Check server status
|
||||
yarn twenty server status
|
||||
|
||||
# Stream server logs
|
||||
yarn twenty server logs
|
||||
|
||||
# Stop the server
|
||||
yarn twenty server stop
|
||||
|
||||
# Reset all data and start fresh
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
The local server comes pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`), so you can start developing immediately without any manual setup.
|
||||
|
||||
### Authentication
|
||||
|
||||
Connect your app to the local server using OAuth:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Authenticate via OAuth (opens browser)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
The scaffolder supports two modes for controlling which example files are included:
|
||||
|
||||
@@ -157,7 +157,9 @@ plugins: [
|
||||
|
||||
Run `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` in database container to get access to admin panel.
|
||||
|
||||
#### When running workflow, workflow run fails with "Logic function execution is disabled. Set LOGIC_FUNCTION_TYPE to LOCAL or LAMBDA to enable."
|
||||
|
||||
In production, logic functions are disabled by default. Set the `LOGIC_FUNCTION_TYPE` environment variable to `LOCAL` or `LAMBDA` to enable them. This can be configured via environment variables or through the admin panel database variables. See the [Logic Functions setup guide](/developers/self-host/capabilities/setup#logic-functions-available-drivers) for details.
|
||||
|
||||
### 1-click Docker compose
|
||||
|
||||
|
||||
@@ -175,7 +175,8 @@
|
||||
"user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -621,7 +622,8 @@
|
||||
"l/fr/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/fr/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/fr/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/fr/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/fr/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/fr/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1067,7 +1069,8 @@
|
||||
"l/ar/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/ar/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/ar/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/ar/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/ar/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/ar/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1513,7 +1516,8 @@
|
||||
"l/cs/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/cs/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/cs/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/cs/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/cs/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/cs/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1959,7 +1963,8 @@
|
||||
"l/de/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/de/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/de/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/de/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/de/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/de/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -2405,7 +2410,8 @@
|
||||
"l/es/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/es/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/es/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/es/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/es/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/es/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -2851,7 +2857,8 @@
|
||||
"l/it/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/it/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/it/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/it/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/it/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/it/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -3297,7 +3304,8 @@
|
||||
"l/ja/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/ja/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/ja/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/ja/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/ja/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/ja/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -3743,7 +3751,8 @@
|
||||
"l/ko/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/ko/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/ko/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/ko/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/ko/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/ko/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -4189,7 +4198,8 @@
|
||||
"l/pt/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/pt/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/pt/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/pt/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/pt/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/pt/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -4635,7 +4645,8 @@
|
||||
"l/ro/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/ro/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/ro/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/ro/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/ro/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/ro/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -5081,7 +5092,8 @@
|
||||
"l/ru/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/ru/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/ru/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/ru/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/ru/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/ru/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -5527,7 +5539,8 @@
|
||||
"l/tr/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/tr/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/tr/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/tr/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/tr/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/tr/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -5973,7 +5986,8 @@
|
||||
"l/zh/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/zh/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/zh/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/zh/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/zh/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/zh/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 931 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 224 KiB |
@@ -21,19 +21,51 @@ description: أنشئ وأدِر تخصيصات Twenty على هيئة كود.
|
||||
## المتطلبات الأساسية
|
||||
|
||||
* Node.js 24+ وYarn 4
|
||||
* مساحة عمل Twenty ومفتاح واجهة برمجة التطبيقات (أنشئ واحدًا على https://app.twenty.com/settings/api-webhooks)
|
||||
* Docker (لخادم تطوير Twenty المحلي)
|
||||
|
||||
## البدء
|
||||
|
||||
أنشئ تطبيقًا جديدًا باستخدام المُهيئ الرسمي، ثم قم بالمصادقة وابدأ التطوير:
|
||||
أنشئ تطبيقًا جديدًا باستخدام المولّد الرسمي. يمكنه بدء مثيل محلي من Twenty تلقائيًا لك:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# إنشاء تطبيق جديد (يتضمن جميع الأمثلة افتراضيًا)
|
||||
# إنشاء تطبيق جديد — ستعرض واجهة سطر الأوامر خيار بدء خادم Twenty محلي
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# ابدأ وضع التطوير: يُزامن التغييرات المحلية تلقائيًا مع مساحة العمل الخاصة بك
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### إدارة الخادم المحلي
|
||||
|
||||
يتضمن SDK أوامر لإدارة خادم تطوير Twenty محلي (صورة Docker متكاملة تتضمن PostgreSQL وRedis والخادم والعامل):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# ابدأ الخادم المحلي (يسحب الصورة إذا لزم الأمر)
|
||||
yarn twenty server start
|
||||
|
||||
# تحقّق من حالة الخادم
|
||||
yarn twenty server status
|
||||
|
||||
# بثّ سجلات الخادم
|
||||
yarn twenty server logs
|
||||
|
||||
# أوقف الخادم
|
||||
yarn twenty server stop
|
||||
|
||||
# أعد ضبط جميع البيانات وابدأ من جديد
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
يأتي الخادم المحلي مهيأً مسبقًا بمساحة عمل ومستخدم (`tim@apple.dev` / `tim@apple.dev`)، بحيث يمكنك البدء في التطوير فورًا دون أي إعداد يدوي.
|
||||
|
||||
### المصادقة
|
||||
|
||||
وصّل تطبيقك بالخادم المحلي باستخدام OAuth:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# المصادقة عبر OAuth (يفتح المتصفح)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
يدعم المُنشئ وضعين للتحكم في ملفات الأمثلة التي سيتم تضمينها:
|
||||
|
||||
@@ -166,6 +166,10 @@ plugins: [
|
||||
|
||||
قم بتشغيل `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` في حاوية قاعدة البيانات للحصول على الوصول إلى لوحة الإدارة.
|
||||
|
||||
#### عند تشغيل سير العمل، يفشل تشغيل سير العمل مع الرسالة "تم تعطيل تنفيذ دوال المنطق. عيّن LOGIC_FUNCTION_TYPE إلى LOCAL أو LAMBDA لتمكين ذلك."
|
||||
|
||||
في بيئة الإنتاج، يتم تعطيل دوال المنطق افتراضيًا. قم بتعيين متغير البيئة `LOGIC_FUNCTION_TYPE` إلى `LOCAL` أو `LAMBDA` لتمكينها. يمكن تكوين ذلك عبر متغيرات البيئة أو من خلال متغيرات قاعدة البيانات في لوحة الإدارة. راجع [دليل إعداد دوال المنطق](/l/ar/developers/self-host/capabilities/setup#logic-functions-available-drivers) للحصول على التفاصيل.
|
||||
|
||||
### Docker compose بنقرة واحدة
|
||||
|
||||
#### غير قادر على تسجيل الدخول
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: الرد التلقائي على رسائل البريد الإلكتروني الواردة
|
||||
description: أنشئ سير عمل يستخدم الذكاء الاصطناعي لفرز رسائل البريد الإلكتروني الواردة وإرسال ردود ضمن سلسلة المحادثة تلقائيًا.
|
||||
---
|
||||
|
||||
استجب لرسائل البريد الإلكتروني الواردة خلال ثوانٍ — وليس ساعات. يستخدم سير العمل هذا وكيلاً للذكاء الاصطناعي لتصفية الضوضاء (النشرات الإخبارية، الرسائل غير المرغوب فيها، الردود التلقائية) وصياغة رد مخصص للرسائل الحقيقية، ثم يرسله كرد مُسلسل داخل المحادثة الأصلية.
|
||||
|
||||
## كيف يعمل تسلسل رسائل البريد الإلكتروني
|
||||
|
||||
تحمل كل رسالة بريد إلكتروني ترويسة `Message-ID` مخفية — بصمة فريدة يعيّنها خادم بريد المرسل. عند الرد على بريد إلكتروني، يقوم عميل البريد لديك بتعيين ترويسة `In-Reply-To` التي تُشير إلى تلك البصمة. بهذه الطريقة يقوم Gmail وOutlook وكل عميل آخر بتجميع الرسائل في سلاسل المحادثات.
|
||||
|
||||
في Twenty، تُخزَّن تلك البصمة كـ `headerMessageId` على كائن Message. يلتقطه سير العمل لديك ويمرّره إلى حقل In-Reply-To لإجراء Send Email.
|
||||
|
||||
## بناء سير العمل
|
||||
|
||||
### الخطوة 1: إنشاء سير عمل جديد
|
||||
|
||||
توجّه إلى **Settings -> Workflows** وانقر **+ New Workflow**.
|
||||
|
||||
### الخطوة 2: التشغيل عند الرسائل الواردة
|
||||
|
||||
اختر **When a Record is Created** ثم **Messages**.
|
||||
|
||||
في كل مرة يصل فيها بريد إلكتروني إلى Twenty، يُشغَّل هذا.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}} />
|
||||
|
||||
### الخطوة 3: البحث عن المُرسِل
|
||||
|
||||
أضِف إجراء **Search Records**.
|
||||
|
||||
عنوان المُرسِل ليس في الرسالة نفسها — بل في سجل Message Participant المرتبط.
|
||||
|
||||
| الحقل | القيمة |
|
||||
| ---------- | ---------------------------------- |
|
||||
| **الكائن** | المشاركون في الرسالة |
|
||||
| **تصفية** | Message **يساوي** `{{trigger.id}}` |
|
||||
| **تصفية** | Role **يساوي** From |
|
||||
| **الحد** | 1 |
|
||||
|
||||
سيمنحك هذا بريد المُرسِل الإلكتروني في `handle` واسمه في `displayName`.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}} />
|
||||
|
||||
### الخطوة 4: الفرز بالذكاء الاصطناعي وصياغة الرد
|
||||
|
||||
أضِف إجراء **AI Agent**. تقوم هذه الخطوة الواحدة بأمرين: تقرر ما إذا كان البريد الإلكتروني يستحق الرد، وإن كان كذلك، تصوغ ردًا.
|
||||
|
||||
استخدم موجهًا مثل:
|
||||
|
||||
```
|
||||
You are an email triage assistant for a sales team. Read the following
|
||||
inbound email and decide if it deserves a reply.
|
||||
|
||||
Subject: {{trigger.subject}}
|
||||
Body: {{trigger.text}}
|
||||
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
|
||||
|
||||
If this email is spam, a newsletter, an automated notification, or
|
||||
otherwise does not need a human reply, respond with exactly: SKIP
|
||||
|
||||
Otherwise, write a short, professional reply (3-4 sentences max) that:
|
||||
- Acknowledges their specific message
|
||||
- Lets them know someone from the team will follow up shortly
|
||||
- Is warm but not overly casual
|
||||
|
||||
Respond with only the reply text, no subject line or greeting prefix.
|
||||
```
|
||||
|
||||
يُنتِج AI Agent استجابته في الحقل `response` الذي يمكن للخطوات التالية الرجوع إليه.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}} />
|
||||
|
||||
### الخطوة 5: التفريع بناءً على قرار الذكاء الاصطناعي
|
||||
|
||||
أضِف إجراء **If/Else** للتحقق مما إذا كان الذكاء الاصطناعي قرر الرد أم التخطي.
|
||||
|
||||
| الحقل | القيمة |
|
||||
| ------------------ | ------------------------------------------- |
|
||||
| **الشرط** | AI Agent `response` **لا يحتوي على** `SKIP` |
|
||||
| **إذا كان صحيحًا** | المتابعة إلى Send Email |
|
||||
| **وإلا** | لا تفعل شيئًا (ينتهي سير العمل) |
|
||||
|
||||
يتم تجاهل الرسائل غير المرغوب فيها والنشرات البريدية والرسائل المُنشأة تلقائيًا. كل ما عدا ذلك ينتقل إلى الخطوة التالية.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}} />
|
||||
|
||||
### الخطوة 6: إرسال رد ضمن سلسلة المحادثة
|
||||
|
||||
أضِف إجراء **Send Email** على فرع "if true". انقر **Advanced options** ثم **Add In-Reply-To**.
|
||||
|
||||
| الحقل | القيمة |
|
||||
| --------------- | -------------------------------------- |
|
||||
| **إلى** | `{{Find Sender.first.handle}}` |
|
||||
| **الموضوع** | `Re: {{trigger.subject}}` |
|
||||
| **المحتوى** | `{{AI Triage & Draft Reply.response}}` |
|
||||
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
|
||||
|
||||
حقل In-Reply-To هو ما يجعل هذا ردًا بدلًا من محادثة جديدة. سيراه المستلِم ضمن سلسلة تحت البريد الأصلي في Gmail أو Outlook أو أي عميل آخر.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}} />
|
||||
|
||||
<Tip>
|
||||
**In-Reply-To** يتوقّع `message.headerMessageId` من المشغّل — إنها البصمة الفريدة للبريد الإلكتروني، وليست عنوان المستلم. إذا تركته فارغًا، فسيُرسَل البريد الإلكتروني على أي حال، ولكن كرسالة مستقلة.
|
||||
</Tip>
|
||||
|
||||
<Warning>
|
||||
يستخدم Gmail سطر الموضوع لتجميع الرسائل في سلاسل المحادثات. يجب أن يبدأ الموضوع بـ `Re:` (بما في ذلك النقطتان والمسافة) ليعرض Gmail الرد داخل سلسلة المحادثة الأصلية. بدون ذلك، سيظهر الرد كمحادثة منفصلة — حتى إذا تم تعيين ترويسة In-Reply-To بشكل صحيح.
|
||||
</Warning>
|
||||
|
||||
### الخطوة 7: الاختبار والتفعيل
|
||||
|
||||
اضغط **Test**، ثم تحقّق من عميل البريد لديك. يجب أن يظهر الرد متداخلًا تحت الرسالة الأصلية.
|
||||
|
||||
فعِّل عندما تكون راضيًا عنه.
|
||||
|
||||
## أفكار للبناء عليها
|
||||
|
||||
* **الرد على كبار الشخصيات فقط** — أضِف فرعًا يتحقق من مجال المُرسِل أو مما إذا كان موجودًا كجهة اتصال في Twenty
|
||||
* **التوجيه حسب النية** — استخدم موجهات AI Agent منفصلة للتعامل مع استفسارات المبيعات بشكل مختلف عن طلبات الدعم
|
||||
* **الإثراء قبل الرد** — أضِف خطوة Search Records لجلب شركة المُرسِل أو سجل الصفقات إلى الموجه الخاص بالذكاء الاصطناعي للحصول على ردود أكثر تخصيصًا
|
||||
@@ -21,19 +21,51 @@ Mit Apps können Sie Twenty-Anpassungen **als Code** erstellen und verwalten. An
|
||||
## Voraussetzungen
|
||||
|
||||
* Node.js 24+ und Yarn 4
|
||||
* Ein Twenty-Workspace und ein API-Schlüssel (unter https://app.twenty.com/settings/api-webhooks erstellen)
|
||||
* Docker (für den lokalen Twenty-Dev-Server)
|
||||
|
||||
## Erste Schritte
|
||||
|
||||
Erstellen Sie mit dem offiziellen Scaffolder eine neue App, authentifizieren Sie sich und beginnen Sie mit der Entwicklung:
|
||||
Erstelle eine neue App mit dem offiziellen Scaffolder. Der Scaffolder kann für dich automatisch eine lokale Twenty-Instanz starten:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Eine neue App erstellen (enthält standardmäßig alle Beispiele)
|
||||
# Eine neue App erstellen — die CLI bietet an, einen lokalen Twenty-Server zu starten
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Dev-Modus starten: synchronisiert lokale Änderungen automatisch mit deinem Arbeitsbereich
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Lokale Serververwaltung
|
||||
|
||||
Das SDK enthält Befehle zur Verwaltung eines lokalen Twenty-Dev-Servers (All-in-One-Docker-Image mit PostgreSQL, Redis, Server und Worker):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Den lokalen Server starten (lädt das Image bei Bedarf herunter)
|
||||
yarn twenty server start
|
||||
|
||||
# Serverstatus prüfen
|
||||
yarn twenty server status
|
||||
|
||||
# Serverprotokolle streamen
|
||||
yarn twenty server logs
|
||||
|
||||
# Server stoppen
|
||||
yarn twenty server stop
|
||||
|
||||
# Alle Daten zurücksetzen und neu starten
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
Der lokale Server ist bereits mit einem Arbeitsbereich und einem Benutzer (`tim@apple.dev` / `tim@apple.dev`) vorbefüllt, sodass Sie ohne manuelle Einrichtung sofort mit der Entwicklung beginnen können.
|
||||
|
||||
### Authentifizierung
|
||||
|
||||
Verbinden Sie Ihre App mithilfe von OAuth mit dem lokalen Server:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Authenticate via OAuth (opens browser)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
Das Scaffolding-Tool unterstützt zwei Modi, um zu steuern, welche Beispieldateien enthalten sind:
|
||||
|
||||
@@ -166,6 +166,10 @@ plugins: [
|
||||
|
||||
Führen Sie den folgenden Befehl im Datenbankcontainer aus, um Zugriff auf das Admin-Panel zu erhalten: `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';`
|
||||
|
||||
#### Beim Ausführen eines Workflows schlägt die Workflow-Ausführung fehl mit "Logic function execution is disabled. Set LOGIC_FUNCTION_TYPE to LOCAL or LAMBDA to enable."
|
||||
|
||||
In der Produktion sind Logikfunktionen standardmäßig deaktiviert. Setzen Sie die Umgebungsvariable `LOGIC_FUNCTION_TYPE` auf `LOCAL` oder `LAMBDA`, um sie zu aktivieren. Dies kann über Umgebungsvariablen oder über die Datenbankvariablen des Admin-Panels konfiguriert werden. Details finden Sie im [Leitfaden zur Einrichtung von Logikfunktionen](/l/de/developers/self-host/capabilities/setup#logic-functions-available-drivers).
|
||||
|
||||
### 1-Klick Docker Compose
|
||||
|
||||
#### Kann mich nicht einloggen
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Automatische Antwort auf eingehende E-Mails
|
||||
description: Erstellen Sie einen Workflow, der KI nutzt, um eingehende E-Mails zu sichten und automatisch Antworten im Thread zu senden.
|
||||
---
|
||||
|
||||
Antworten Sie auf eingehende E-Mails in Sekunden — nicht in Stunden. Dieser Workflow verwendet einen KI-Agenten, um Störendes auszufiltern (Newsletter, Spam, automatische Antworten) und eine personalisierte Antwort auf echte Nachrichten zu verfassen und sie dann als Antwort im Thread innerhalb der ursprünglichen Unterhaltung zu senden.
|
||||
|
||||
## Wie E-Mail-Threading funktioniert
|
||||
|
||||
Jede E-Mail enthält einen verborgenen `Message-ID`-Header — einen eindeutigen Fingerabdruck, der vom Mailserver des Absenders zugewiesen wird. Wenn Sie auf eine E-Mail antworten, setzt Ihr E-Mail-Client einen `In-Reply-To`-Header, der auf diesen Fingerabdruck verweist. So gruppieren Gmail, Outlook und alle anderen Clients Nachrichten in Threads.
|
||||
|
||||
In Twenty wird dieser Fingerabdruck als `headerMessageId` am Message-Objekt gespeichert. Ihr Workflow greift ihn auf und übergibt ihn an das Feld In-Reply-To der Aktion Send Email.
|
||||
|
||||
## Den Workflow erstellen
|
||||
|
||||
### Schritt 1: Neuen Workflow erstellen
|
||||
|
||||
Gehen Sie zu **Settings -> Workflows** und klicken Sie auf **+ New Workflow**.
|
||||
|
||||
### Schritt 2: Bei eingehenden Nachrichten auslösen
|
||||
|
||||
Wählen Sie **When a Record is Created** und dann **Messages**.
|
||||
|
||||
Jedes Mal, wenn eine E-Mail in Twenty eingeht, wird dies ausgelöst.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}} />
|
||||
|
||||
### Schritt 3: Absender ermitteln
|
||||
|
||||
Fügen Sie eine **Datensätze suchen**-Aktion hinzu.
|
||||
|
||||
Die Absenderadresse befindet sich nicht in der Nachricht selbst — sie steht im zugehörigen Datensatz Message Participant.
|
||||
|
||||
| Feld | Wert |
|
||||
| ---------- | -------------------------------- |
|
||||
| **Objekt** | Nachrichtenteilnehmer |
|
||||
| **Filter** | Message **ist** `{{trigger.id}}` |
|
||||
| **Filter** | Rolle **ist** From |
|
||||
| **Limit** | 1 |
|
||||
|
||||
Dadurch erhalten Sie die E-Mail-Adresse des Absenders in `handle` und den Namen in `displayName`.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}} />
|
||||
|
||||
### Schritt 4: KI-Triage und Antwortentwurf
|
||||
|
||||
Fügen Sie eine **AI Agent**-Aktion hinzu. Dieser einzelne Schritt erledigt zwei Dinge: Er entscheidet, ob die E-Mail eine Antwort verdient, und falls ja, verfasst er eine.
|
||||
|
||||
Verwenden Sie einen Prompt wie:
|
||||
|
||||
```
|
||||
You are an email triage assistant for a sales team. Read the following
|
||||
inbound email and decide if it deserves a reply.
|
||||
|
||||
Subject: {{trigger.subject}}
|
||||
Body: {{trigger.text}}
|
||||
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
|
||||
|
||||
If this email is spam, a newsletter, an automated notification, or
|
||||
otherwise does not need a human reply, respond with exactly: SKIP
|
||||
|
||||
Otherwise, write a short, professional reply (3-4 sentences max) that:
|
||||
- Acknowledges their specific message
|
||||
- Lets them know someone from the team will follow up shortly
|
||||
- Is warm but not overly casual
|
||||
|
||||
Respond with only the reply text, no subject line or greeting prefix.
|
||||
```
|
||||
|
||||
Der AI Agent gibt seine Antwort in einem Feld `response` aus, auf das sich die nächsten Schritte beziehen können.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}} />
|
||||
|
||||
### Schritt 5: Verzweigen anhand der KI-Entscheidung
|
||||
|
||||
Fügen Sie eine **If/Else**-Aktion hinzu, um zu prüfen, ob die KI sich entschieden hat zu antworten oder zu überspringen.
|
||||
|
||||
| Feld | Wert |
|
||||
| ------------- | -------------------------------------------- |
|
||||
| **Bedingung** | AI Agent `response` **enthält nicht** `SKIP` |
|
||||
| **Wenn wahr** | Mit Send Email fortfahren |
|
||||
| **Sonst** | Nichts tun (Workflow endet) |
|
||||
|
||||
Spam, Newsletter und automatisch erzeugte Nachrichten werden verworfen. Alles andere geht zum nächsten Schritt über.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}} />
|
||||
|
||||
### Schritt 6: Eine Antwort im Thread senden
|
||||
|
||||
Fügen Sie im "Wenn wahr"-Zweig eine **Send Email**-Aktion hinzu. Klicken Sie auf **Advanced options**, dann **Add In-Reply-To**.
|
||||
|
||||
| Feld | Wert |
|
||||
| --------------- | -------------------------------------- |
|
||||
| **An** | `{{Find Sender.first.handle}}` |
|
||||
| **Betreff** | `Re: {{trigger.subject}}` |
|
||||
| **Body** | `{{AI Triage & Draft Reply.response}}` |
|
||||
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
|
||||
|
||||
Das Feld In-Reply-To sorgt dafür, dass dies eine Antwort und keine neue Unterhaltung ist. Der Empfänger sieht sie als Teil des Threads unter der ursprünglichen E-Mail in Gmail, Outlook oder jedem anderen Client.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}} />
|
||||
|
||||
<Tip>
|
||||
**In-Reply-To** erwartet eine `message.headerMessageId` aus dem Trigger — das ist der eindeutige Fingerabdruck der E-Mail, keine Empfängeradresse. Wenn Sie es leer lassen, wird die E-Mail trotzdem gesendet, nur als eigenständige Nachricht.
|
||||
</Tip>
|
||||
|
||||
<Warning>
|
||||
Gmail verwendet die Betreffzeile, um Nachrichten in Threads zu gruppieren. Der Betreff **muss** mit `Re:` beginnen (einschließlich Doppelpunkt und Leerzeichen), damit Gmail die Antwort im ursprünglichen Thread anzeigt. Ohne dies erscheint die Antwort als separate Unterhaltung — selbst wenn der In-Reply-To-Header korrekt gesetzt ist.
|
||||
</Warning>
|
||||
|
||||
### Schritt 7: Testen und aktivieren
|
||||
|
||||
Klicken Sie auf **Test**, und prüfen Sie dann Ihren E-Mail-Client. Die Antwort sollte unter der ursprünglichen Nachricht verschachtelt erscheinen.
|
||||
|
||||
Aktivieren Sie ihn, wenn Sie zufrieden sind.
|
||||
|
||||
## Ideen zum Ausbau
|
||||
|
||||
* **Nur an VIPs antworten** — fügen Sie einen Zweig hinzu, der die Domain des Absenders prüft oder ob er in Twenty als Kontakt existiert
|
||||
* **Nach Absicht routen** — verwenden Sie separate AI Agent-Prompts, um Vertriebsanfragen anders zu bearbeiten als Supportanfragen
|
||||
* **Vor der Antwort anreichern** — fügen Sie einen Schritt Search Records hinzu, um das Unternehmen oder die Deal-Historie des Absenders in den AI-Prompt zu übernehmen, für persönlichere Antworten
|
||||
@@ -21,19 +21,51 @@ Le app ti consentono di creare e gestire le personalizzazioni di Twenty **come c
|
||||
## Prerequisiti
|
||||
|
||||
* Node.js 24+ e Yarn 4
|
||||
* Uno spazio di lavoro Twenty e una chiave API (creane una su https://app.twenty.com/settings/api-webhooks)
|
||||
* Docker (per il server di sviluppo locale di Twenty)
|
||||
|
||||
## Per iniziare
|
||||
|
||||
Crea una nuova app utilizzando lo scaffolder ufficiale, quindi autenticati e inizia a sviluppare:
|
||||
Crea una nuova app utilizzando lo scaffolder ufficiale. Può avviare automaticamente un'istanza locale di Twenty per te:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Crea lo scaffold di una nuova app (include tutti gli esempi per impostazione predefinita)
|
||||
# Crea lo scaffold di una nuova app — la CLI offrirà di avviare un server locale di Twenty
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Avvia la modalità di sviluppo: sincronizza automaticamente le modifiche locali con il tuo workspace
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Gestione del server locale
|
||||
|
||||
L'SDK include comandi per gestire un server di sviluppo locale di Twenty (immagine Docker all-in-one con PostgreSQL, Redis, server e worker):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Avvia il server locale (scarica l'immagine se necessario)
|
||||
yarn twenty server start
|
||||
|
||||
# Verifica lo stato del server
|
||||
yarn twenty server status
|
||||
|
||||
# Segui i log del server
|
||||
yarn twenty server logs
|
||||
|
||||
# Arresta il server
|
||||
yarn twenty server stop
|
||||
|
||||
# Reimposta tutti i dati e riparti da zero
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
Il server locale è preconfigurato con uno spazio di lavoro e un utente (`tim@apple.dev` / `tim@apple.dev`), così puoi iniziare a sviluppare immediatamente senza alcuna configurazione manuale.
|
||||
|
||||
### Autenticazione
|
||||
|
||||
Collega la tua app al server locale tramite OAuth:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Autenticati tramite OAuth (apre il browser)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
Lo strumento di scaffolding supporta due modalità per controllare quali file di esempio vengono inclusi:
|
||||
|
||||
@@ -167,6 +167,10 @@ plugins: [
|
||||
|
||||
Esegui `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` nel container del database per accedere al pannello di amministrazione.
|
||||
|
||||
#### Durante l'esecuzione di un workflow, l'esecuzione del workflow non riesce con "L'esecuzione delle funzioni logiche è disabilitata. Imposta LOGIC_FUNCTION_TYPE su LOCAL o LAMBDA per abilitarle."
|
||||
|
||||
In produzione, le funzioni logiche sono disabilitate per impostazione predefinita. Imposta la variabile d'ambiente `LOGIC_FUNCTION_TYPE` su `LOCAL` o `LAMBDA` per abilitarle. Questo può essere configurato tramite variabili d'ambiente o tramite le variabili del database del pannello di amministrazione. Consulta la [guida alla configurazione delle funzioni logiche](/l/it/developers/self-host/capabilities/setup#logic-functions-available-drivers) per dettagli.
|
||||
|
||||
### Composizione Docker a un clic
|
||||
|
||||
#### Impossibile connettersi
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Risposta automatica alle email in arrivo
|
||||
description: Crea un flusso di lavoro che utilizza l'IA per smistare le email in arrivo e inviare automaticamente risposte in thread.
|
||||
---
|
||||
|
||||
Rispondi alle email in arrivo in pochi secondi — non ore. Questo flusso di lavoro utilizza un agente IA per filtrare il rumore (newsletter, spam, risposte automatiche) e redigere una risposta personalizzata ai messaggi reali, quindi la invia come risposta in thread all'interno della conversazione originale.
|
||||
|
||||
## Come funziona il threading delle email
|
||||
|
||||
Ogni email include un'intestazione nascosta `Message-ID` — un'impronta univoca assegnata dal server di posta del mittente. Quando rispondi a un'email, il tuo client di posta imposta un'intestazione `In-Reply-To` che fa riferimento a quell'impronta. È così che Gmail, Outlook e tutti gli altri client raggruppano i messaggi nei thread.
|
||||
|
||||
In Twenty, quell'impronta è memorizzata come `headerMessageId` sull'oggetto Message. Il tuo flusso di lavoro la recupera e la passa al campo In-Reply-To dell'azione Send Email.
|
||||
|
||||
## Creazione del flusso di lavoro
|
||||
|
||||
### Passaggio 1: Crea un nuovo flusso di lavoro
|
||||
|
||||
Vai a **Impostazioni -> Flussi di lavoro** e fai clic su **+ New Workflow**.
|
||||
|
||||
### Passaggio 2: Imposta il trigger sui messaggi in arrivo
|
||||
|
||||
Scegli **When a Record is Created** e seleziona **Messages**.
|
||||
|
||||
Ogni volta che un'email arriva in Twenty, questo si attiva.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}} />
|
||||
|
||||
### Passaggio 3: Cerca chi l'ha inviato
|
||||
|
||||
Aggiungi un'azione **Search Records**.
|
||||
|
||||
L'indirizzo del mittente non è presente sul messaggio stesso — si trova nel record Message Participant correlato.
|
||||
|
||||
| Campo | Valore |
|
||||
| ----------- | ------------------------------- |
|
||||
| **Oggetto** | Partecipanti al messaggio |
|
||||
| **Filtro** | Message **is** `{{trigger.id}}` |
|
||||
| **Filtro** | Role **is** From |
|
||||
| **Limite** | 1 |
|
||||
|
||||
Questo ti fornisce l'email del mittente in `handle` e il suo nome in `displayName`.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}} />
|
||||
|
||||
### Passaggio 4: Triage con AI e bozza di risposta
|
||||
|
||||
Aggiungi un'azione **AI Agent**. Questo singolo passaggio fa due cose: decide se l'email merita una risposta e, in tal caso, ne scrive una.
|
||||
|
||||
Usa un prompt come:
|
||||
|
||||
```
|
||||
You are an email triage assistant for a sales team. Read the following
|
||||
inbound email and decide if it deserves a reply.
|
||||
|
||||
Subject: {{trigger.subject}}
|
||||
Body: {{trigger.text}}
|
||||
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
|
||||
|
||||
If this email is spam, a newsletter, an automated notification, or
|
||||
otherwise does not need a human reply, respond with exactly: SKIP
|
||||
|
||||
Otherwise, write a short, professional reply (3-4 sentences max) that:
|
||||
- Acknowledges their specific message
|
||||
- Lets them know someone from the team will follow up shortly
|
||||
- Is warm but not overly casual
|
||||
|
||||
Respond with only the reply text, no subject line or greeting prefix.
|
||||
```
|
||||
|
||||
AI Agent produce la sua risposta in un campo `response` a cui i passaggi successivi possono fare riferimento.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}} />
|
||||
|
||||
### Passaggio 5: Ramifica in base alla decisione dell'AI
|
||||
|
||||
Aggiungi un'azione **If/Else** per verificare se l'AI ha deciso di rispondere o saltare.
|
||||
|
||||
| Campo | Valore |
|
||||
| -------------- | ----------------------------------------------- |
|
||||
| **Condizione** | AI Agent `response` **does not contain** `SKIP` |
|
||||
| **Se vero** | Continua con Send Email |
|
||||
| **Altrimenti** | Non fare nulla (il flusso di lavoro termina) |
|
||||
|
||||
Spam, newsletter e messaggi generati automaticamente vengono scartati. Tutto il resto passa al passaggio successivo.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}} />
|
||||
|
||||
### Passaggio 6: Invia una risposta nello stesso thread
|
||||
|
||||
Aggiungi un'azione **Send Email** nel ramo "if true". Fai clic su **Advanced options**, quindi su **Add In-Reply-To**.
|
||||
|
||||
| Campo | Valore |
|
||||
| --------------- | -------------------------------------- |
|
||||
| **A** | `{{Find Sender.first.handle}}` |
|
||||
| **Oggetto** | `Re: {{trigger.subject}}` |
|
||||
| **Corpo** | `{{AI Triage & Draft Reply.response}}` |
|
||||
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
|
||||
|
||||
Il campo In-Reply-To è ciò che rende questo un messaggio di risposta invece di una nuova conversazione. Il destinatario lo vede nel thread sotto l'email originale in Gmail, Outlook o qualsiasi altro client.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}} />
|
||||
|
||||
<Tip>
|
||||
**In-Reply-To** si aspetta un `message.headerMessageId` dal trigger — è l'impronta univoca dell'email, non un indirizzo del destinatario. Se lo lasci vuoto, l'email viene comunque inviata, solo come messaggio autonomo.
|
||||
</Tip>
|
||||
|
||||
<Warning>
|
||||
Gmail usa l'oggetto per raggruppare i messaggi nei thread. L'oggetto **deve** iniziare con `Re:` (inclusi i due punti e lo spazio) affinché Gmail visualizzi la risposta all'interno del thread originale. Senza di esso, la risposta apparirà come una conversazione separata — anche se l'intestazione In-Reply-To è impostata correttamente.
|
||||
</Warning>
|
||||
|
||||
### Passaggio 7: Testa e attiva
|
||||
|
||||
Fai clic su **Test**, quindi controlla il tuo client di posta. La risposta dovrebbe comparire annidata sotto il messaggio originale.
|
||||
|
||||
Attivalo quando sei soddisfatto.
|
||||
|
||||
## Idee per sviluppare ulteriormente
|
||||
|
||||
* **Rispondi solo ai VIP** — aggiungi un ramo che controlli il dominio del mittente o se esiste come Contatto in Twenty
|
||||
* **Instrada in base all'intento** — usa prompt distinti di AI Agent per gestire le richieste commerciali in modo diverso rispetto a quelle di supporto
|
||||
* **Arricchisci prima di rispondere** — aggiungi un passaggio Search Records per inserire nel prompt dell'AI l'azienda del mittente o la cronologia delle trattative per risposte più personalizzate
|
||||
@@ -21,19 +21,51 @@ Os aplicativos permitem criar e gerenciar personalizações do Twenty **como có
|
||||
## Pré-requisitos
|
||||
|
||||
* Node.js 24+ e Yarn 4
|
||||
* Um espaço de trabalho do Twenty e uma chave de API (crie uma em https://app.twenty.com/settings/api-webhooks)
|
||||
* Docker (para o servidor de desenvolvimento local do Twenty)
|
||||
|
||||
## Primeiros passos
|
||||
|
||||
Crie um novo aplicativo usando o gerador oficial, depois autentique-se e comece a desenvolver:
|
||||
Crie um novo app usando o gerador oficial de estrutura. Ele pode iniciar automaticamente uma instância local do Twenty para você:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Criar a estrutura de um novo app (inclui todos os exemplos por padrão)
|
||||
# Criar a estrutura de um novo app — a CLI oferecerá iniciar um servidor local do Twenty
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Iniciar modo de desenvolvimento: sincroniza automaticamente as alterações locais com seu workspace
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Gerenciamento do Servidor Local
|
||||
|
||||
O SDK inclui comandos para gerenciar um servidor de desenvolvimento local do Twenty (imagem Docker all-in-one com PostgreSQL, Redis, servidor e worker):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Iniciar o servidor local (faz pull da imagem se necessário)
|
||||
yarn twenty server start
|
||||
|
||||
# Verificar o status do servidor
|
||||
yarn twenty server status
|
||||
|
||||
# Transmitir os logs do servidor
|
||||
yarn twenty server logs
|
||||
|
||||
# Parar o servidor
|
||||
yarn twenty server stop
|
||||
|
||||
# Redefinir todos os dados e começar do zero
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
O servidor local já vem pré-configurado com um espaço de trabalho e um usuário (`tim@apple.dev` / `tim@apple.dev`), para que você possa começar a desenvolver imediatamente, sem qualquer configuração manual.
|
||||
|
||||
### Autenticação
|
||||
|
||||
Conecte seu aplicativo ao servidor local usando OAuth:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Authenticate via OAuth (opens browser)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
O gerador de estrutura oferece suporte a dois modos para controlar quais arquivos de exemplo são incluídos:
|
||||
|
||||
@@ -166,6 +166,10 @@ plugins: [
|
||||
|
||||
Execute `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'você@seudominio.com';` no contêiner de banco de dados para obter acesso ao painel administrativo.
|
||||
|
||||
#### Ao executar um fluxo de trabalho, a execução do fluxo de trabalho falha com "A execução da função de lógica está desativada. Defina LOGIC_FUNCTION_TYPE como LOCAL ou LAMBDA para ativar."
|
||||
|
||||
Em produção, as funções de lógica estão desativadas por padrão. Defina a variável de ambiente `LOGIC_FUNCTION_TYPE` como `LOCAL` ou `LAMBDA` para ativá-las. Isso pode ser configurado por meio de variáveis de ambiente ou pelas variáveis de banco de dados do painel de administração. Veja o [guia de configuração de Funções de Lógica](/l/pt/developers/self-host/capabilities/setup#logic-functions-available-drivers) para obter detalhes.
|
||||
|
||||
### Docker compose com um clique
|
||||
|
||||
#### Impossível efetuar login
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Resposta automática a e-mails de entrada
|
||||
description: Crie um fluxo de trabalho que use IA para fazer a triagem dos e-mails de entrada e enviar respostas encadeadas automaticamente.
|
||||
---
|
||||
|
||||
Responda a e-mails de entrada em segundos — não em horas. Este fluxo de trabalho usa um Agente de IA para filtrar o ruído (boletins informativos, spam, respostas automáticas) e redigir uma resposta personalizada para mensagens reais e, em seguida, enviá-la como uma resposta encadeada dentro da conversa original.
|
||||
|
||||
## Como funciona o encadeamento de e-mails
|
||||
|
||||
Cada e-mail contém um cabeçalho oculto `Message-ID` — uma impressão digital única atribuída pelo servidor de e-mail do remetente. Quando você responde a um e-mail, seu cliente de e-mail define um cabeçalho `In-Reply-To` que faz referência a essa impressão digital. É assim que o Gmail, o Outlook e todos os outros clientes agrupam as mensagens em conversas.
|
||||
|
||||
No Twenty, essa impressão digital é armazenada como `headerMessageId` no objeto Message. Seu fluxo de trabalho obtém isso e o passa para o campo In-Reply-To da ação Enviar e-mail.
|
||||
|
||||
## Criando o fluxo de trabalho
|
||||
|
||||
### Etapa 1: Criar um novo fluxo de trabalho
|
||||
|
||||
Vá para **Configurações -> Fluxos de trabalho** e clique em **+ Novo fluxo de trabalho**.
|
||||
|
||||
### Etapa 2: Acionar com mensagens recebidas
|
||||
|
||||
Escolha **When a Record is Created** e selecione **Messages**.
|
||||
|
||||
Sempre que um e-mail chega ao Twenty, isto é acionado.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}} />
|
||||
|
||||
### Etapa 3: Procurar quem enviou
|
||||
|
||||
Adicione uma ação **Pesquisar Registros**.
|
||||
|
||||
O endereço do remetente não está na própria mensagem — está no registro relacionado Message Participant.
|
||||
|
||||
| Campo | Valor |
|
||||
| ---------- | ------------------------------ |
|
||||
| **Objeto** | Participantes da Mensagem |
|
||||
| **Filtro** | Message **é** `{{trigger.id}}` |
|
||||
| **Filtro** | Função **é** From |
|
||||
| **Limite** | 1 |
|
||||
|
||||
Isso fornece o e-mail do remetente em `handle` e o nome dele em `displayName`.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}} />
|
||||
|
||||
### Etapa 4: Triagem por IA e rascunho de resposta
|
||||
|
||||
Adicione uma ação **Agente de IA**. Esta única etapa faz duas coisas: decide se o e-mail merece uma resposta e, se sim, redige uma.
|
||||
|
||||
Use um prompt como:
|
||||
|
||||
```
|
||||
You are an email triage assistant for a sales team. Read the following
|
||||
inbound email and decide if it deserves a reply.
|
||||
|
||||
Subject: {{trigger.subject}}
|
||||
Body: {{trigger.text}}
|
||||
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
|
||||
|
||||
If this email is spam, a newsletter, an automated notification, or
|
||||
otherwise does not need a human reply, respond with exactly: SKIP
|
||||
|
||||
Otherwise, write a short, professional reply (3-4 sentences max) that:
|
||||
- Acknowledges their specific message
|
||||
- Lets them know someone from the team will follow up shortly
|
||||
- Is warm but not overly casual
|
||||
|
||||
Respond with only the reply text, no subject line or greeting prefix.
|
||||
```
|
||||
|
||||
O Agente de IA produz sua resposta em um campo `response` ao qual as próximas etapas podem se referir.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}} />
|
||||
|
||||
### Etapa 5: Ramificar com base na decisão da IA
|
||||
|
||||
Adicione uma ação **If/Else** para verificar se a IA decidiu responder ou pular.
|
||||
|
||||
| Campo | Valor |
|
||||
| ------------------ | -------------------------------------------------- |
|
||||
| **Condição** | A `response` do Agente de IA **não contém** `SKIP` |
|
||||
| **Se verdadeiro** | Continuar para Enviar e-mail |
|
||||
| **Caso contrário** | Não fazer nada (o fluxo de trabalho termina) |
|
||||
|
||||
Spam, boletins informativos e mensagens geradas automaticamente são descartados. Todo o restante segue para a próxima etapa.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}} />
|
||||
|
||||
### Etapa 6: Enviar uma resposta encadeada
|
||||
|
||||
Adicione uma ação **Enviar e-mail** no ramo "se verdadeiro". Clique em **Opções avançadas** e depois em **Adicionar In-Reply-To**.
|
||||
|
||||
| Campo | Valor |
|
||||
| --------------- | -------------------------------------- |
|
||||
| **Para** | `{{Find Sender.first.handle}}` |
|
||||
| **Assunto** | `Re: {{trigger.subject}}` |
|
||||
| **Corpo** | `{{AI Triage & Draft Reply.response}}` |
|
||||
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
|
||||
|
||||
O campo In-Reply-To é o que faz disso uma resposta em vez de uma nova conversa. O destinatário a vê encadeada sob o e-mail original no Gmail, Outlook ou qualquer outro cliente.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}} />
|
||||
|
||||
<Tip>
|
||||
**In-Reply-To** espera um `message.headerMessageId` do acionador — é a impressão digital única do e-mail, não um endereço do destinatário. Se você deixá-lo vazio, o e-mail ainda será enviado, apenas como uma mensagem independente.
|
||||
</Tip>
|
||||
|
||||
<Warning>
|
||||
O Gmail usa a linha de assunto para agrupar mensagens em conversas. O assunto **deve** começar com `Re:` (incluindo os dois-pontos e o espaço) para que o Gmail exiba a resposta dentro da conversa original. Sem isso, a resposta aparecerá como uma conversa separada — mesmo que o cabeçalho In-Reply-To esteja definido corretamente.
|
||||
</Warning>
|
||||
|
||||
### Etapa 7: Testar e ativar
|
||||
|
||||
Clique em **Test** e, em seguida, verifique seu cliente de e-mail. A resposta deve aparecer aninhada sob a mensagem original.
|
||||
|
||||
Ative quando estiver satisfeito com isso.
|
||||
|
||||
## Ideias para desenvolver
|
||||
|
||||
* **Responder apenas a VIPs** — adicione um ramo que verifique o domínio do remetente ou se ele existe como um Contato no Twenty
|
||||
* **Roteie por intenção** — use prompts separados do Agente de IA para lidar com consultas de vendas de forma diferente de solicitações de suporte
|
||||
* **Enriquecer antes de responder** — adicione uma etapa Pesquisar Registros para trazer a empresa do remetente ou o histórico de negócios para o prompt de IA, obtendo respostas mais personalizadas
|
||||
@@ -21,19 +21,51 @@ Aplicațiile vă permit să construiți și să gestionați personalizările Twe
|
||||
## Cerințe
|
||||
|
||||
* Node.js 24+ și Yarn 4
|
||||
* Un spațiu de lucru Twenty și o cheie API (creați una la https://app.twenty.com/settings/api-webhooks)
|
||||
* Docker (pentru serverul local de dezvoltare Twenty)
|
||||
|
||||
## Începeți
|
||||
|
||||
Creați o aplicație nouă folosind generatorul oficial, apoi autentificați-vă și începeți să dezvoltați:
|
||||
Creează o aplicație nouă folosind generatorul oficial. Poate porni automat o instanță Twenty locală pentru tine:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Creează scheletul unei aplicații noi (include toate exemplele în mod implicit)
|
||||
# Creează scheletul unei aplicații noi — CLI-ul îți va oferi opțiunea de a porni un server Twenty local
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Pornește modul de dezvoltare: sincronizează automat modificările locale cu spațiul tău de lucru
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Gestionarea serverului local
|
||||
|
||||
SDK-ul include comenzi pentru a gestiona un server local de dezvoltare Twenty (imagine Docker all-in-one cu PostgreSQL, Redis, server și worker):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Pornește serverul local (descarcă imaginea dacă este necesar)
|
||||
yarn twenty server start
|
||||
|
||||
# Verifică starea serverului
|
||||
yarn twenty server status
|
||||
|
||||
# Afișează în timp real jurnalele serverului
|
||||
yarn twenty server logs
|
||||
|
||||
# Oprește serverul
|
||||
yarn twenty server stop
|
||||
|
||||
# Resetează toate datele și pornește de la zero
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
Serverul local vine preconfigurat cu un spațiu de lucru și un utilizator (`tim@apple.dev` / `tim@apple.dev`), astfel încât să poți începe să dezvolți imediat, fără nicio configurare manuală.
|
||||
|
||||
### Autentificare
|
||||
|
||||
Conectează-ți aplicația la serverul local folosind OAuth:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Autentifică-te prin OAuth (se deschide browserul)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
Generatorul de schelet acceptă două moduri pentru a controla ce fișiere de exemplu sunt incluse:
|
||||
|
||||
@@ -167,6 +167,10 @@ plugins: [
|
||||
|
||||
Rulați `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'tine@domeniultău.com';` în containerul de baze de date pentru a obține acces la panoul de administrare.
|
||||
|
||||
#### Când rulați un flux de lucru, rularea fluxului de lucru eșuează cu "Execuția funcțiilor logice este dezactivată. Setați LOGIC_FUNCTION_TYPE la LOCAL sau LAMBDA pentru a le activa."
|
||||
|
||||
În producție, funcțiile logice sunt dezactivate în mod implicit. Setați variabila de mediu `LOGIC_FUNCTION_TYPE` la `LOCAL` sau `LAMBDA` pentru a le activa. Acest lucru poate fi configurat prin variabile de mediu sau prin variabilele bazei de date din panoul de administrare. Consultați [Ghidul de configurare a funcțiilor logice](/l/ro/developers/self-host/capabilities/setup#logic-functions-available-drivers) pentru detalii.
|
||||
|
||||
### Docker compose cu un singur click
|
||||
|
||||
#### Nu se poate conecta
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Răspuns automat la e-mailurile primite
|
||||
description: Creează un flux de lucru care folosește IA pentru a tria e-mailurile primite și pentru a trimite automat răspunsuri în același fir.
|
||||
---
|
||||
|
||||
Răspunde la e-mailurile primite în câteva secunde — nu în ore. Acest flux de lucru folosește un Agent IA pentru a filtra zgomotul (newslettere, spam, răspunsuri automate) și pentru a redacta un răspuns personalizat la mesajele reale, apoi îl trimite ca răspuns în același fir, în cadrul conversației originale.
|
||||
|
||||
## Cum funcționează firele de e-mail
|
||||
|
||||
Fiecare e-mail are un antet ascuns `Message-ID` — o amprentă unică atribuită de serverul de e-mail al expeditorului. Când răspundeți la un e-mail, clientul dvs. de e-mail setează un antet `In-Reply-To` care face referire la acea amprentă. Astfel, Gmail, Outlook și orice alt client grupează mesajele în fire de discuție.
|
||||
|
||||
În Twenty, acea amprentă este stocată ca `headerMessageId` pe obiectul Message. Fluxul dvs. de lucru o preia și o transmite în câmpul In-Reply-To al acțiunii Send Email.
|
||||
|
||||
## Construirea fluxului de lucru
|
||||
|
||||
### Pasul 1: Creați un flux de lucru nou
|
||||
|
||||
Accesați **Settings -> Workflows** și faceți clic pe **+ New Workflow**.
|
||||
|
||||
### Pasul 2: Declanșare la mesajele primite
|
||||
|
||||
Alegeți **When a Record is Created** și selectați **Messages**.
|
||||
|
||||
De fiecare dată când un e-mail ajunge în Twenty, aceasta se declanșează.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}} />
|
||||
|
||||
### Pasul 3: Căutați cine l-a trimis
|
||||
|
||||
Adăugați o acțiune **Search Records**.
|
||||
|
||||
Adresa expeditorului nu este în mesajul propriu-zis — se găsește în înregistrarea asociată Message Participant.
|
||||
|
||||
| Câmp | Valoare |
|
||||
| ---------- | --------------------------------- |
|
||||
| **Obiect** | Participanți la mesaj |
|
||||
| **Filtru** | Mesajul **este** `{{trigger.id}}` |
|
||||
| **Filtru** | Rolul **este** From |
|
||||
| **Limită** | 1 |
|
||||
|
||||
Aceasta vă oferă e-mailul expeditorului în `handle` și numele acestuia în `displayName`.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}} />
|
||||
|
||||
### Pasul 4: Triere cu AI și redactarea răspunsului
|
||||
|
||||
Adăugați o acțiune **AI Agent**. Acest singur pas face două lucruri: decide dacă e-mailul merită un răspuns și, dacă da, redactează unul.
|
||||
|
||||
Folosiți un prompt de tipul:
|
||||
|
||||
```
|
||||
You are an email triage assistant for a sales team. Read the following
|
||||
inbound email and decide if it deserves a reply.
|
||||
|
||||
Subject: {{trigger.subject}}
|
||||
Body: {{trigger.text}}
|
||||
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
|
||||
|
||||
If this email is spam, a newsletter, an automated notification, or
|
||||
otherwise does not need a human reply, respond with exactly: SKIP
|
||||
|
||||
Otherwise, write a short, professional reply (3-4 sentences max) that:
|
||||
- Acknowledges their specific message
|
||||
- Lets them know someone from the team will follow up shortly
|
||||
- Is warm but not overly casual
|
||||
|
||||
Respond with only the reply text, no subject line or greeting prefix.
|
||||
```
|
||||
|
||||
AI Agent produce răspunsul în câmpul `response`, la care pot face referire pașii următori.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}} />
|
||||
|
||||
### Pasul 5: Ramificați în funcție de decizia AI
|
||||
|
||||
Adăugați o acțiune **If/Else** pentru a verifica dacă AI a decis să răspundă sau să o omită.
|
||||
|
||||
| Câmp | Valoare |
|
||||
| ---------------------- | -------------------------------------------- |
|
||||
| **Condiție** | AI Agent `response` **nu conține** `SKIP` |
|
||||
| **Dacă este adevărat** | Continuați la Send Email |
|
||||
| **Altfel** | Nu faceți nimic (fluxul de lucru se încheie) |
|
||||
|
||||
Spam-ul, buletinele informative și mesajele generate automat sunt ignorate. Tot restul trece la pasul următor.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}} />
|
||||
|
||||
### Pasul 6: Trimiteți un răspuns în același fir
|
||||
|
||||
Adăugați o acțiune **Send Email** pe ramura "if true". Faceți clic pe **Advanced options**, apoi pe **Add In-Reply-To**.
|
||||
|
||||
| Câmp | Valoare |
|
||||
| --------------- | -------------------------------------- |
|
||||
| **Către** | `{{Find Sender.first.handle}}` |
|
||||
| **Subiect** | `Re: {{trigger.subject}}` |
|
||||
| **Body** | `{{AI Triage & Draft Reply.response}}` |
|
||||
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
|
||||
|
||||
Câmpul In-Reply-To este cel care face ca acesta să fie un răspuns, nu o conversație nouă. Destinatarul îl vede afișat în fir sub e-mailul original în Gmail, Outlook sau orice alt client.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}} />
|
||||
|
||||
<Tip>
|
||||
**In-Reply-To** necesită un `message.headerMessageId` din declanșator — este amprenta unică a e-mailului, nu o adresă a destinatarului. Dacă îl lăsați necompletat, e-mailul se trimite în continuare, doar ca un mesaj independent.
|
||||
</Tip>
|
||||
|
||||
<Warning>
|
||||
Gmail folosește subiectul pentru a grupa mesajele în fire de discuție. Subiectul **trebuie** să înceapă cu `Re:` (inclusiv două puncte și spațiu) pentru ca Gmail să afișeze răspunsul în cadrul firului original. Fără acesta, răspunsul va apărea ca o conversație separată — chiar dacă antetul In-Reply-To este setat corect.
|
||||
</Warning>
|
||||
|
||||
### Pasul 7: Testați și activați
|
||||
|
||||
Apăsați **Test**, apoi verificați clientul de e-mail. Răspunsul ar trebui să apară sub mesajul original.
|
||||
|
||||
Activați când sunteți mulțumit(ă) de rezultat.
|
||||
|
||||
## Idei pe care să le dezvoltați
|
||||
|
||||
* **Răspundeți doar VIP-urilor** — adăugați o ramură care verifică domeniul expeditorului sau dacă acesta există ca Contact în Twenty
|
||||
* **Dirijați în funcție de intenție** — folosiți prompturi AI Agent separate pentru a trata diferit solicitările de vânzări față de cererile de asistență
|
||||
* **Îmbogățiți înainte de a răspunde** — adăugați un pas Search Records pentru a include compania expeditorului sau istoricul tranzacțiilor în promptul AI, pentru răspunsuri mai personalizate
|
||||
@@ -21,19 +21,51 @@ description: Создавайте и управляйте настройками
|
||||
## Требования
|
||||
|
||||
* Node.js 24+ и Yarn 4
|
||||
* Рабочее пространство Twenty и ключ API (создайте его на https://app.twenty.com/settings/api-webhooks)
|
||||
* Docker (для локального сервера разработки Twenty)
|
||||
|
||||
## Начало работы
|
||||
|
||||
Создайте новое приложение с помощью официального генератора, затем выполните аутентификацию и начните разработку:
|
||||
Создайте новое приложение с помощью официального генератора каркаса. Он может автоматически запустить локальный экземпляр Twenty:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Создать каркас нового приложения (по умолчанию включает все примеры)
|
||||
# Создать каркас нового приложения — CLI предложит запустить локальный сервер Twenty
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Запустить режим разработки: автоматически синхронизирует локальные изменения с вашим рабочим пространством
|
||||
yarn twenty app:dev
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Управление локальным сервером
|
||||
|
||||
SDK включает команды для управления локальным сервером разработки Twenty (универсальный образ Docker с PostgreSQL, Redis, сервером и воркером):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Запустить локальный сервер (при необходимости будет загружен образ)
|
||||
yarn twenty server start
|
||||
|
||||
# Проверить статус сервера
|
||||
yarn twenty server status
|
||||
|
||||
# Просмотр логов сервера в реальном времени
|
||||
yarn twenty server logs
|
||||
|
||||
# Остановить сервер
|
||||
yarn twenty server stop
|
||||
|
||||
# Сбросить все данные и начать с нуля
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
Локальный сервер уже содержит рабочее пространство и пользователя (`tim@apple.dev` / `tim@apple.dev`), так что вы можете сразу начать разработку без какой-либо ручной настройки.
|
||||
|
||||
### Аутентификация
|
||||
|
||||
Подключите своё приложение к локальному серверу с помощью OAuth:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Authenticate via OAuth (opens browser)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
Генератор каркаса поддерживает два режима для управления тем, какие файлы-примеры включаются:
|
||||
|
||||
@@ -167,6 +167,10 @@ plugins: [
|
||||
|
||||
Выполните `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` в контейнере базы данных, чтобы получить доступ к административной панели.
|
||||
|
||||
#### При запуске рабочего процесса выполнение завершается ошибкой: "Выполнение логической функции отключено. Установите LOGIC_FUNCTION_TYPE в значение LOCAL или LAMBDA, чтобы их включить.
|
||||
|
||||
В производственной среде логические функции по умолчанию отключены. Установите переменную окружения `LOGIC_FUNCTION_TYPE` в `LOCAL` или `LAMBDA`, чтобы их включить. Это можно настроить через переменные окружения или через переменные базы данных в панели администратора. См. [руководство по настройке логических функций](/l/ru/developers/self-host/capabilities/setup#logic-functions-available-drivers) для получения подробной информации.
|
||||
|
||||
### Docker Compose в один клик
|
||||
|
||||
#### Не удается войти в систему
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Автоответ на входящие письма
|
||||
description: Создайте рабочий процесс, который использует ИИ для сортировки входящих писем и автоматически отправляет ответы в рамках цепочки.
|
||||
---
|
||||
|
||||
Отвечайте на входящие письма за секунды — а не за часы. Этот рабочий процесс использует ИИ-агента, чтобы отфильтровать шум (рассылки, спам, автоответы) и подготовить персонализированный ответ на реальные сообщения, а затем отправляет его как ответ в цепочке внутри исходной переписки.
|
||||
|
||||
## Как работают цепочки писем
|
||||
|
||||
Каждое письмо содержит скрытый заголовок `Message-ID` — уникальный отпечаток, назначаемый почтовым сервером отправителя. Когда вы отвечаете на электронное письмо, ваш почтовый клиент устанавливает заголовок `In-Reply-To`, ссылающийся на этот отпечаток. Именно так Gmail, Outlook и другие клиенты группируют сообщения в цепочки.
|
||||
|
||||
В Twenty этот отпечаток хранится как `headerMessageId` в объекте Message. Ваш рабочий процесс получает его и передает в поле In-Reply-To действия Send Email.
|
||||
|
||||
## Создание рабочего процесса
|
||||
|
||||
### Шаг 1: Создайте новый рабочий процесс
|
||||
|
||||
Перейдите в **Настройки → Рабочие процессы** и нажмите **+ New Workflow**.
|
||||
|
||||
### Шаг 2: Настройте триггер на входящие сообщения
|
||||
|
||||
Выберите **When a Record is Created** и укажите **Messages**.
|
||||
|
||||
Каждый раз, когда письмо попадает в Twenty, этот триггер срабатывает.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}} />
|
||||
|
||||
### Шаг 3: Найдите, кто отправил письмо
|
||||
|
||||
Добавьте действие **Search Records**.
|
||||
|
||||
Адрес отправителя находится не в самом сообщении — он в связанной записи Message Participant.
|
||||
|
||||
| Поле | Значение |
|
||||
| ---------- | ---------------------------------- |
|
||||
| **Объект** | Участники сообщения |
|
||||
| **Фильтр** | Message **равно** `{{trigger.id}}` |
|
||||
| **Фильтр** | Role **равно** From |
|
||||
| **Лимит** | 1 |
|
||||
|
||||
Это дает вам адрес электронной почты отправителя в `handle` и его имя в `displayName`.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}} />
|
||||
|
||||
### Шаг 4: Классификация ИИ и черновик ответа
|
||||
|
||||
Добавьте действие **AI Agent**. Этот шаг делает две вещи: решает, заслуживает ли письмо ответа, и, если да, составляет ответ.
|
||||
|
||||
Используйте подсказку вроде:
|
||||
|
||||
```
|
||||
You are an email triage assistant for a sales team. Read the following
|
||||
inbound email and decide if it deserves a reply.
|
||||
|
||||
Subject: {{trigger.subject}}
|
||||
Body: {{trigger.text}}
|
||||
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
|
||||
|
||||
If this email is spam, a newsletter, an automated notification, or
|
||||
otherwise does not need a human reply, respond with exactly: SKIP
|
||||
|
||||
Otherwise, write a short, professional reply (3-4 sentences max) that:
|
||||
- Acknowledges their specific message
|
||||
- Lets them know someone from the team will follow up shortly
|
||||
- Is warm but not overly casual
|
||||
|
||||
Respond with only the reply text, no subject line or greeting prefix.
|
||||
```
|
||||
|
||||
AI Agent выводит свой ответ в поле `response`, к которому могут обращаться следующие шаги.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}} />
|
||||
|
||||
### Шаг 5: Ветвление по решению ИИ
|
||||
|
||||
Добавьте действие **If/Else**, чтобы проверить, решил ли ИИ ответить или пропустить.
|
||||
|
||||
| Поле | Значение |
|
||||
| -------------- | ---------------------------------------------- |
|
||||
| **Условие** | AI Agent `response` **не содержит** `SKIP` |
|
||||
| **Если верно** | Перейти к Send Email |
|
||||
| **Иначе** | Ничего не делать (рабочий процесс завершается) |
|
||||
|
||||
Спам, рассылки и автоматически сгенерированные сообщения отбрасываются. Все остальное переходит к следующему шагу.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}} />
|
||||
|
||||
### Шаг 6: Отправьте ответ в цепочке
|
||||
|
||||
Добавьте действие **Send Email** в ветке "Если верно". Нажмите **Advanced options**, затем **Add In-Reply-To**.
|
||||
|
||||
| Поле | Значение |
|
||||
| --------------- | -------------------------------------- |
|
||||
| **Кому** | `{{Find Sender.first.handle}}` |
|
||||
| **Тема** | `Re: {{trigger.subject}}` |
|
||||
| **Текст** | `{{AI Triage & Draft Reply.response}}` |
|
||||
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
|
||||
|
||||
Поле In-Reply-To делает это письмом-ответом, а не новой перепиской. Получатель увидит его в той же цепочке под исходным письмом в Gmail, Outlook или любом другом клиенте.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}} />
|
||||
|
||||
<Tip>
|
||||
**In-Reply-To** ожидает `message.headerMessageId` из триггера — это уникальный отпечаток письма, а не адрес получателя. Если оставить его пустым, письмо всё равно отправится, но как отдельное сообщение.
|
||||
</Tip>
|
||||
|
||||
<Warning>
|
||||
Gmail использует тему письма, чтобы группировать сообщения в цепочки. Тема письма **должна** начинаться с `Re:` (включая двоеточие и пробел), чтобы Gmail показывал ответ внутри исходной цепочки. Без этого ответ будет отображаться как отдельная переписка — даже если заголовок In-Reply-To установлен правильно.
|
||||
</Warning>
|
||||
|
||||
### Шаг 7: Протестируйте и активируйте
|
||||
|
||||
Нажмите **Test**, затем проверьте ваш почтовый клиент. Ответ должен появиться под исходным сообщением.
|
||||
|
||||
Активируйте, когда будете довольны результатом.
|
||||
|
||||
## Идеи для развития
|
||||
|
||||
* **Отвечать только VIP-адресатам** — добавьте ветку, которая проверяет домен отправителя или то, существует ли он как Контакт в Twenty
|
||||
* **Маршрутизация по намерению** — используйте отдельные подсказки для AI Agent, чтобы обрабатывать запросы по продажам иначе, чем обращения в поддержку
|
||||
* **Обогатите данные перед ответом** — добавьте шаг Search Records, чтобы передать компанию отправителя или историю сделок в подсказку ИИ для более персонализированных ответов
|
||||
@@ -21,19 +21,51 @@ Uygulamalar, Twenty özelleştirmelerini **kod olarak** oluşturup yönetmenizi
|
||||
## Ön Gereksinimler
|
||||
|
||||
* Node.js 24+ ve Yarn 4
|
||||
* Bir Twenty çalışma alanı ve bir API anahtarı (https://app.twenty.com/settings/api-webhooks adresinde oluşturun)
|
||||
* Docker (yerel Twenty geliştirme sunucusu için)
|
||||
|
||||
## Başlarken
|
||||
|
||||
Resmi scaffolder aracını kullanarak yeni bir uygulama oluşturun, ardından kimlik doğrulaması yapıp geliştirmeye başlayın:
|
||||
Resmi iskelet oluşturucusunu kullanarak yeni bir uygulama oluşturun. Sizin için otomatik olarak yerel bir Twenty örneğini başlatabilir:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Yeni bir uygulamanın iskeletini oluşturun (varsayılan olarak tüm örnekleri içerir)
|
||||
# Yeni bir uygulamanın iskeletini oluşturun — CLI yerel bir Twenty sunucusunu başlatmayı önerecektir
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Geliştirme modunu başlatın: yerel değişiklikleri otomatik olarak çalışma alanınızla senkronize eder
|
||||
yarn twenty app:dev
|
||||
# Geliştirme modunu başlatın: yerel değişiklikleri çalışma alanınızla otomatik olarak senkronize eder
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### Yerel Sunucu Yönetimi
|
||||
|
||||
SDK, yerel bir Twenty geliştirme sunucusunu yönetmek için komutlar içerir (PostgreSQL, Redis, sunucu ve worker içeren hepsi bir arada Docker imajı):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Yerel sunucuyu başlatın (gerekirse imajı indirir)
|
||||
yarn twenty server start
|
||||
|
||||
# Sunucu durumunu kontrol edin
|
||||
yarn twenty server status
|
||||
|
||||
# Sunucu günlüklerini akış olarak görüntüleyin
|
||||
yarn twenty server logs
|
||||
|
||||
# Sunucuyu durdurun
|
||||
yarn twenty server stop
|
||||
|
||||
# Tüm verileri sıfırlayın ve temiz bir başlangıç yapın
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
Yerel sunucu, bir çalışma alanı ve kullanıcıyla (`tim@apple.dev` / `tim@apple.dev`) önceden yapılandırılmış olarak gelir; böylece herhangi bir manuel kurulum gerektirmeden hemen geliştirmeye başlayabilirsiniz.
|
||||
|
||||
### Kimlik Doğrulama
|
||||
|
||||
Uygulamanızı OAuth kullanarak yerel sunucuya bağlayın:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Authenticate via OAuth (opens browser)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
İskelet oluşturucu, hangi örnek dosyaların dahil edileceğini kontrol etmek için iki modu destekler:
|
||||
|
||||
@@ -166,6 +166,10 @@ plugins: [
|
||||
|
||||
Veritabanı konteynerinde `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` komutunu çalıştırarak yönetim paneline erişim sağlayın.
|
||||
|
||||
#### Bir iş akışı çalıştırılırken, iş akışının çalıştırılması şu hatayla başarısız oluyor: "Mantık işlevi yürütme devre dışı. Etkinleştirmek için LOGIC_FUNCTION_TYPE değerini LOCAL veya LAMBDA olarak ayarlayın."
|
||||
|
||||
Üretimde, mantık işlevleri varsayılan olarak devre dışıdır. Bunları etkinleştirmek için `LOGIC_FUNCTION_TYPE` ortam değişkenini `LOCAL` veya `LAMBDA` olarak ayarlayın. Bu, ortam değişkenleri aracılığıyla veya yönetici panelindeki veritabanı değişkenleri üzerinden yapılandırılabilir. Ayrıntılar için [Mantık İşlevleri kurulum kılavuzu](/l/tr/developers/self-host/capabilities/setup#logic-functions-available-drivers) bölümüne bakın.
|
||||
|
||||
### 1-tıklama ile Docker Compose
|
||||
|
||||
#### Giriş Yapılamıyor
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Gelen E-postalara Otomatik Yanıt
|
||||
description: Gelen e-postaları önceliklendirmek için Yapay Zeka (YZ) kullanan ve ileti dizisi içinde yanıtları otomatik olarak gönderen bir iş akışı oluşturun.
|
||||
---
|
||||
|
||||
Gelen e-postalara saatler değil, saniyeler içinde yanıt verin. Bu iş akışı, gereksiz iletileri (bültenler, spam, otomatik yanıtlar) ayıklamak ve gerçek iletiler için kişiselleştirilmiş bir yanıt taslağı oluşturmak üzere bir YZ aracısı kullanır; ardından bunu orijinal konuşmadaki ileti dizisinde bir yanıt olarak gönderir.
|
||||
|
||||
## E-posta İleti Dizisi Nasıl Çalışır
|
||||
|
||||
Her e-posta, göndericinin posta sunucusu tarafından atanan benzersiz bir parmak izi olan gizli bir `Message-ID` üstbilgisi taşır. Bir e-postayı yanıtladığınızda, posta istemciniz o parmak izine referans veren bir `In-Reply-To` başlığı ayarlar. Gmail, Outlook ve diğer tüm istemciler iletileri bu şekilde ileti dizilerine gruplandırır.
|
||||
|
||||
Twenty’de, bu parmak izi Message nesnesinde `headerMessageId` olarak saklanır. İş akışınız bunu alır ve E-posta Gönder eyleminin In-Reply-To alanına aktarır.
|
||||
|
||||
## İş akışını oluşturma
|
||||
|
||||
### Adım 1: Yeni bir İş Akışı oluşturun
|
||||
|
||||
**Ayarlar -> İş Akışları** bölümüne gidin ve **+ Yeni İş Akışı**'na tıklayın.
|
||||
|
||||
### Adım 2: Gelen iletilerde tetikleyin
|
||||
|
||||
**When a Record is Created** seçeneğini seçin ve **Messages**'ı seçin.
|
||||
|
||||
Her e-posta Twenty'ye ulaştığında bu tetiklenir.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}} />
|
||||
|
||||
### Adım 3: Kimin gönderdiğini bulun
|
||||
|
||||
Bir **Kayıtları Ara** eylemi ekleyin.
|
||||
|
||||
Gönderenin adresi iletinin üzerinde değil — ilgili Message Participant kaydında.
|
||||
|
||||
| Alan | Değer |
|
||||
| ---------- | ------------------------------- |
|
||||
| **Nesne** | Mesaj Katılımcıları |
|
||||
| **Filtre** | Message **is** `{{trigger.id}}` |
|
||||
| **Filtre** | Role **is** From |
|
||||
| **Sınır** | 1 |
|
||||
|
||||
Bu, size gönderenin e-posta adresini `handle` içinde ve adını `displayName` içinde verir.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}} />
|
||||
|
||||
### Adım 4: Yapay zekâ triyajı ve yanıt taslağı
|
||||
|
||||
Bir **AI Agent** eylemi ekleyin. Bu tek adım iki şey yapar: e-postanın yanıtı hak edip etmediğine karar verir ve eğer ediyorsa bir yanıt yazar.
|
||||
|
||||
Şuna benzer bir istem kullanın:
|
||||
|
||||
```
|
||||
You are an email triage assistant for a sales team. Read the following
|
||||
inbound email and decide if it deserves a reply.
|
||||
|
||||
Subject: {{trigger.subject}}
|
||||
Body: {{trigger.text}}
|
||||
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
|
||||
|
||||
If this email is spam, a newsletter, an automated notification, or
|
||||
otherwise does not need a human reply, respond with exactly: SKIP
|
||||
|
||||
Otherwise, write a short, professional reply (3-4 sentences max) that:
|
||||
- Acknowledges their specific message
|
||||
- Lets them know someone from the team will follow up shortly
|
||||
- Is warm but not overly casual
|
||||
|
||||
Respond with only the reply text, no subject line or greeting prefix.
|
||||
```
|
||||
|
||||
AI Agent, yanıtını sonraki adımların başvurabileceği `response` alanına yazar.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}} />
|
||||
|
||||
### Adım 5: Yapay zekâ kararına göre dallandırın
|
||||
|
||||
Yapay zekânın yanıtlamaya mı yoksa atlamaya mı karar verdiğini kontrol etmek için bir **If/Else** eylemi ekleyin.
|
||||
|
||||
| Alan | Değer |
|
||||
| ----------- | ----------------------------------------------- |
|
||||
| **Koşul** | AI Agent `response` **does not contain** `SKIP` |
|
||||
| **If true** | E-posta Gönder adımına devam edin |
|
||||
| **Else** | Hiçbir şey yapmayın (iş akışı sona erer) |
|
||||
|
||||
Spam, bültenler ve otomatik oluşturulan iletiler atlanır. Diğer her şey bir sonraki adıma geçer.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}} />
|
||||
|
||||
### Adım 6: İleti dizisine bağlı bir yanıt gönderin
|
||||
|
||||
"if true" dalına bir **E-posta Gönder** eylemi ekleyin. **Advanced options**'a tıklayın, ardından **Add In-Reply-To**.
|
||||
|
||||
| Alan | Değer |
|
||||
| --------------- | -------------------------------------- |
|
||||
| **Kime** | `{{Find Sender.first.handle}}` |
|
||||
| **Konu** | `Re: {{trigger.subject}}` |
|
||||
| **Gövde** | `{{AI Triage & Draft Reply.response}}` |
|
||||
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
|
||||
|
||||
In-Reply-To alanı, bunun yeni bir konuşma yerine bir yanıt olmasını sağlar. Alıcı, Gmail, Outlook veya diğer herhangi bir istemcide bunu özgün e-postanın altında dizili olarak görür.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}} />
|
||||
|
||||
<Tip>
|
||||
**In-Reply-To**, tetikleyiciden bir `message.headerMessageId` bekler — bu bir alıcı adresi değil, e-postanın benzersiz parmak izidir. Bunu boş bırakırsanız e-posta yine de gönderilir, yalnızca bağımsız bir ileti olarak.
|
||||
</Tip>
|
||||
|
||||
<Warning>
|
||||
Gmail, iletileri diziler halinde gruplamak için konu satırını kullanır. Gmail’in yanıtı özgün dizinin içinde göstermesi için konu mutlaka `Re:` ile başlamalıdır (iki nokta ve boşluk dahil). Bu olmadan, In-Reply-To başlığı doğru ayarlanmış olsa bile yanıt ayrı bir konuşma olarak görünür.
|
||||
</Warning>
|
||||
|
||||
### Adım 7: Test Edin ve Etkinleştirin
|
||||
|
||||
**Test**'e basın, ardından e-posta istemcinizi kontrol edin. Yanıt, özgün iletinin altında dizili olarak görünmelidir.
|
||||
|
||||
İçinize sindiğinde etkinleştirin.
|
||||
|
||||
## Geliştirmek için fikirler
|
||||
|
||||
* **Yalnızca VIP'lere yanıt verin** — gönderenin alan adını veya Twenty’de bir Contact olarak bulunup bulunmadığını kontrol eden bir dal ekleyin
|
||||
* **Niyete göre yönlendirin** — satış sorgularını destek taleplerinden farklı şekilde ele almak için ayrı AI Agent istemleri kullanın
|
||||
* **Yanıtlamadan önce zenginleştirin** — daha kişiselleştirilmiş yanıtlar için gönderenin şirketini veya anlaşma geçmişini AI istemine çekmek üzere bir Search Records adımı ekleyin
|
||||
@@ -21,19 +21,51 @@ description: 以代码的形式构建并管理 Twenty 自定义项。
|
||||
## 先决条件
|
||||
|
||||
* Node.js 24+ 和 Yarn 4
|
||||
* 一个 Twenty 工作空间和一个 API 密钥(在 https://app.twenty.com/settings/api-webhooks 创建)
|
||||
* Docker (用于本地 Twenty 开发服务器)
|
||||
|
||||
## 开始使用
|
||||
|
||||
使用官方脚手架创建一个新应用,然后进行身份验证并开始开发:
|
||||
使用官方脚手架创建一个新应用。 它可以为你自动启动一个本地 Twenty 实例:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# 创建一个新应用脚手架(默认包含所有示例)
|
||||
# Scaffold a new app — the CLI will offer to start a local Twenty server
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# 启动开发模式:会将本地更改自动同步到你的工作区
|
||||
yarn twenty app:dev
|
||||
# Start dev mode: automatically syncs local changes to your workspace
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
### 本地服务器管理
|
||||
|
||||
该 SDK 包含用于管理本地 Twenty 开发服务器的命令(该服务器是一体化 Docker 镜像,内含 PostgreSQL、Redis、服务器和工作进程):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Start the local server (pulls the image if needed)
|
||||
yarn twenty server start
|
||||
|
||||
# Check server status
|
||||
yarn twenty server status
|
||||
|
||||
# Stream server logs
|
||||
yarn twenty server logs
|
||||
|
||||
# Stop the server
|
||||
yarn twenty server stop
|
||||
|
||||
# Reset all data and start fresh
|
||||
yarn twenty server reset
|
||||
```
|
||||
|
||||
本地服务器预置了一个工作区和用户 (`tim@apple.dev` / `tim@apple.dev`),因此你可以无需任何手动设置即可立即开始开发。
|
||||
|
||||
### 身份验证
|
||||
|
||||
使用 OAuth 将你的应用连接到本地服务器:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# 通过 OAuth 进行身份验证 (将打开浏览器)
|
||||
yarn twenty remote add --local
|
||||
```
|
||||
|
||||
脚手架工具支持两种模式,用于控制包含哪些示例文件:
|
||||
|
||||
@@ -166,6 +166,10 @@ plugins: [
|
||||
|
||||
在数据库容器中运行 `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` 以访问管理面板。
|
||||
|
||||
#### 在运行工作流时,工作流运行失败,并提示 "Logic function execution is disabled." 将 LOGIC_FUNCTION_TYPE 设置为 LOCAL 或 LAMBDA 以启用。
|
||||
|
||||
在生产环境中,逻辑函数默认处于禁用状态。 将 `LOGIC_FUNCTION_TYPE` 环境变量设置为 `LOCAL` 或 `LAMBDA` 以启用它们。 这可以通过环境变量或管理面板的数据库变量进行配置。 详情请参见[逻辑函数设置指南](/l/zh/developers/self-host/capabilities/setup#logic-functions-available-drivers)。
|
||||
|
||||
### 一键使用 Docker Compose
|
||||
|
||||
#### 无法登录
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: 入站邮件自动回复
|
||||
description: 构建一个使用 AI 对入站邮件进行分拣,并自动以线程形式发送回复的工作流。
|
||||
---
|
||||
|
||||
在数秒内回复入站邮件—而不是数小时。 此工作流使用 AI 智能体筛除噪音(新闻通讯、垃圾邮件、自动回复),为真实邮件起草个性化回复,并在原始会话中以线程形式发送该回复。
|
||||
|
||||
## 电子邮件线程如何运作
|
||||
|
||||
每封电子邮件都包含一个隐藏的 `Message-ID` 头部—由发件人的邮件服务器分配的唯一指纹。 当您回复一封电子邮件时,您的邮件客户端会设置一个 `In-Reply-To` 标头以引用该指纹。 这就是 Gmail、Outlook 以及其他所有客户端将邮件分组为会话的方式。
|
||||
|
||||
在 Twenty 中,该指纹作为 `headerMessageId` 存储在 Message 对象中。 您的工作流会获取它,并将其传递到 Send Email 操作的 In-Reply-To 字段。
|
||||
|
||||
## 构建工作流
|
||||
|
||||
### 步骤 1:创建新工作流
|
||||
|
||||
前往 **Settings -> Workflows** 并单击 **+ New Workflow**。
|
||||
|
||||
### 步骤 2:在收到邮件时触发
|
||||
|
||||
选择 **When a Record is Created**,然后选择 **Messages**。
|
||||
|
||||
每当有邮件进入 Twenty 时,这一步就会触发。
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}} />
|
||||
|
||||
### 步骤 3:查找发件人
|
||||
|
||||
添加 **搜索记录** 操作.
|
||||
|
||||
发件人的地址不在消息本身上——而是在关联的 Message Participant 记录中。
|
||||
|
||||
| 字段 | 值 |
|
||||
| ------- | ------------------------------ |
|
||||
| **对象** | 消息参与者 |
|
||||
| **筛选器** | Message **为** `{{trigger.id}}` |
|
||||
| **筛选器** | Role **为** From |
|
||||
| **限制** | 1 |
|
||||
|
||||
这会在 `handle` 中提供发件人的邮箱地址,在 `displayName` 中提供其姓名。
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}} />
|
||||
|
||||
### 步骤 4:AI 分流并起草回复
|
||||
|
||||
添加一个 **AI Agent** 操作。 此单一步骤完成两件事:决定该邮件是否需要回复;如果需要,则撰写一封回复。
|
||||
|
||||
使用类似如下的提示词:
|
||||
|
||||
```
|
||||
You are an email triage assistant for a sales team. Read the following
|
||||
inbound email and decide if it deserves a reply.
|
||||
|
||||
Subject: {{trigger.subject}}
|
||||
Body: {{trigger.text}}
|
||||
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
|
||||
|
||||
If this email is spam, a newsletter, an automated notification, or
|
||||
otherwise does not need a human reply, respond with exactly: SKIP
|
||||
|
||||
Otherwise, write a short, professional reply (3-4 sentences max) that:
|
||||
- Acknowledges their specific message
|
||||
- Lets them know someone from the team will follow up shortly
|
||||
- Is warm but not overly casual
|
||||
|
||||
Respond with only the reply text, no subject line or greeting prefix.
|
||||
```
|
||||
|
||||
AI Agent 会将其响应输出到 `response` 字段,后续步骤可以引用该字段。
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}} />
|
||||
|
||||
### 步骤 5:根据 AI 的决策进行分支
|
||||
|
||||
添加一个 **If/Else** 操作,用于检查 AI 决定是回复还是跳过。
|
||||
|
||||
| 字段 | 值 |
|
||||
| ------- | ---------------------------------- |
|
||||
| **条件** | AI Agent `response` **不包含** `SKIP` |
|
||||
| **若为真** | 继续执行 Send Email |
|
||||
| **否则** | 不执行任何操作(工作流结束) |
|
||||
|
||||
垃圾邮件、新闻简报以及自动生成的邮件会被丢弃。 其余内容将进入下一步。
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}} />
|
||||
|
||||
### 步骤 6:发送会话内回复
|
||||
|
||||
在 "若为真" 分支上添加一个 **Send Email** 操作。 单击 **Advanced options**,然后选择 **Add In-Reply-To**。
|
||||
|
||||
| 字段 | 值 |
|
||||
| --------------- | -------------------------------------- |
|
||||
| **收件人** | `{{Find Sender.first.handle}}` |
|
||||
| **主题** | `Re: {{trigger.subject}}` |
|
||||
| **正文** | `{{AI Triage & Draft Reply.response}}` |
|
||||
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
|
||||
|
||||
正是 In-Reply-To 字段使其成为一次回复,而不是一段新的会话。 收件人在 Gmail、Outlook 或任何其他客户端中,会看到它被归入原始邮件下的同一会话。
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}} />
|
||||
|
||||
<Tip>
|
||||
**In-Reply-To** 需要来自触发器的 `message.headerMessageId`——它是该邮件的唯一指纹,而不是收件人地址。 如果将其留空,邮件仍会发送,只是作为一封独立的消息。
|
||||
</Tip>
|
||||
|
||||
<Warning>
|
||||
Gmail 使用主题行将邮件归入同一会话。 要让 Gmail 在原始会话中显示回复,主题行**必须**以 `Re:` 开头(包括冒号和空格)。 否则,即使正确设置了 In-Reply-To 标头,回复也会显示为一个单独的会话。
|
||||
</Warning>
|
||||
|
||||
### 步骤 7:测试并启用
|
||||
|
||||
点击 **Test**,然后检查您的邮件客户端。 回复应当嵌套显示在原始邮件下方。
|
||||
|
||||
确认无误后启用。
|
||||
|
||||
## 进阶构建思路
|
||||
|
||||
* **仅回复 VIP** — 添加一个分支,用于检查发件人的域名,或他们是否在 Twenty 中作为联系人存在
|
||||
* **按意图路由** — 使用不同的 AI Agent 提示词,将销售咨询与支持请求区分处理
|
||||
* **回复前先丰富信息** — 添加一个 Search Records 步骤,将发件人的公司或交易历史拉入 AI 提示词中,以获得更个性化的回复
|
||||
@@ -157,7 +157,8 @@
|
||||
"user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -156,7 +156,8 @@
|
||||
"user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Auto-Reply to Inbound Emails
|
||||
description: Build a workflow that uses AI to triage incoming emails and send threaded replies automatically.
|
||||
---
|
||||
|
||||
Respond to inbound emails in seconds — not hours. This workflow uses an AI Agent to filter out noise (newsletters, spam, auto-replies) and draft a personalized response to real messages, then sends it as a threaded reply inside the original conversation.
|
||||
|
||||
## How email threading works
|
||||
|
||||
Every email carries a hidden `Message-ID` header — a unique fingerprint assigned by the sender's mail server. When you reply to an email, your mail client sets an `In-Reply-To` header referencing that fingerprint. That's how Gmail, Outlook, and every other client groups messages into threads.
|
||||
|
||||
In Twenty, that fingerprint is stored as `headerMessageId` on the Message object. Your workflow grabs it and passes it into the Send Email action's In-Reply-To field.
|
||||
|
||||
## Building the workflow
|
||||
|
||||
### Step 1: Create a new workflow
|
||||
|
||||
Head to **Settings -> Workflows** and click **+ New Workflow**.
|
||||
|
||||
### Step 2: Trigger on incoming messages
|
||||
|
||||
Pick **When a Record is Created** and choose **Messages**.
|
||||
|
||||
Every time an email lands in Twenty, this fires.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}}/>
|
||||
|
||||
### Step 3: Look up who sent it
|
||||
|
||||
Add a **Search Records** action.
|
||||
|
||||
The sender's address isn't on the message itself — it's on the related Message Participant record.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Object** | Message Participants |
|
||||
| **Filter** | Message **is** `{{trigger.id}}` |
|
||||
| **Filter** | Role **is** From |
|
||||
| **Limit** | 1 |
|
||||
|
||||
This gives you the sender's email in `handle` and their name in `displayName`.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}}/>
|
||||
|
||||
### Step 4: AI triage and draft reply
|
||||
|
||||
Add an **AI Agent** action. This single step does two things: decides whether the email deserves a reply, and if so, writes one.
|
||||
|
||||
Use a prompt like:
|
||||
|
||||
```
|
||||
You are an email triage assistant for a sales team. Read the following
|
||||
inbound email and decide if it deserves a reply.
|
||||
|
||||
Subject: {{trigger.subject}}
|
||||
Body: {{trigger.text}}
|
||||
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
|
||||
|
||||
If this email is spam, a newsletter, an automated notification, or
|
||||
otherwise does not need a human reply, respond with exactly: SKIP
|
||||
|
||||
Otherwise, write a short, professional reply (3-4 sentences max) that:
|
||||
- Acknowledges their specific message
|
||||
- Lets them know someone from the team will follow up shortly
|
||||
- Is warm but not overly casual
|
||||
|
||||
Respond with only the reply text, no subject line or greeting prefix.
|
||||
```
|
||||
|
||||
The AI Agent outputs its response in a `response` field that the next steps can reference.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}}/>
|
||||
|
||||
### Step 5: Branch on the AI decision
|
||||
|
||||
Add an **If/Else** action to check whether the AI decided to reply or skip.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Condition** | AI Agent `response` **does not contain** `SKIP` |
|
||||
| **If true** | Continue to Send Email |
|
||||
| **Else** | Do nothing (workflow ends) |
|
||||
|
||||
Spam, newsletters, and auto-generated messages get dropped. Everything else moves to the next step.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}}/>
|
||||
|
||||
### Step 6: Send a threaded reply
|
||||
|
||||
Add a **Send Email** action on the "if true" branch. Click **Advanced options**, then **Add In-Reply-To**.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **To** | `{{Find Sender.first.handle}}` |
|
||||
| **Subject** | `Re: {{trigger.subject}}` |
|
||||
| **Body** | `{{AI Triage & Draft Reply.response}}` |
|
||||
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
|
||||
|
||||
The In-Reply-To field is what makes this a reply instead of a new conversation. The recipient sees it threaded under the original email in Gmail, Outlook, or any other client.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}}/>
|
||||
|
||||
<Tip>
|
||||
**In-Reply-To** expects a `message.headerMessageId` from the trigger — it's the email's unique fingerprint, not a recipient address. If you leave it empty, the email still sends, just as a standalone message.
|
||||
</Tip>
|
||||
|
||||
<Warning>
|
||||
Gmail uses the subject line to group messages into threads. The subject **must** start with `Re:` (including the colon and space) for Gmail to display the reply inside the original thread. Without it, the reply will appear as a separate conversation — even if the In-Reply-To header is set correctly.
|
||||
</Warning>
|
||||
|
||||
### Step 7: Test and activate
|
||||
|
||||
Hit **Test**, then check your email client. The reply should appear nested under the original message.
|
||||
|
||||
Activate when you're happy with it.
|
||||
|
||||
## Ideas to build on
|
||||
|
||||
- **Only reply to VIPs** — add a branch that checks the sender's domain or whether they exist as a Contact in Twenty
|
||||
- **Route by intent** — use separate AI Agent prompts to handle sales inquiries differently from support requests
|
||||
- **Enrich before replying** — add a Search Records step to pull the sender's company or deal history into the AI prompt for more personalized replies
|
||||
@@ -3,7 +3,7 @@ import * as projectAnnotations from './preview';
|
||||
|
||||
// Pre-warm the dynamic import used by WorkflowStepDecorator so the
|
||||
// module is cached before any test runs (avoids flaky timeouts in CI).
|
||||
import('~/testing/utils/generatedMockObjectMetadataItems');
|
||||
import('~/testing/utils/getTestEnrichedObjectMetadataItemsMock');
|
||||
|
||||
// This is an important step to apply the right configuration when testing your stories.
|
||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* oxlint-disable no-console, lingui/no-unlocalized-strings */
|
||||
import { print } from 'graphql';
|
||||
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { generateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromObject';
|
||||
import { generateFindManyRecordsQuery } from '@/object-record/utils/generateFindManyRecordsQuery';
|
||||
|
||||
@@ -29,7 +29,7 @@ const addTypenamesToSelections = (query: string): string =>
|
||||
|
||||
const toObjectMetadataItems = (rawMetadata: {
|
||||
objects: { edges: { node: Record<string, unknown> }[] };
|
||||
}): ObjectMetadataItem[] =>
|
||||
}): EnrichedObjectMetadataItem[] =>
|
||||
rawMetadata.objects.edges.map((edge) => {
|
||||
const { fieldsList, indexMetadataList, ...rest } = edge.node;
|
||||
|
||||
@@ -44,12 +44,12 @@ const toObjectMetadataItems = (rawMetadata: {
|
||||
indexFieldMetadatas: index.indexFieldMetadataList ?? [],
|
||||
}),
|
||||
),
|
||||
} as unknown as ObjectMetadataItem;
|
||||
} as unknown as EnrichedObjectMetadataItem;
|
||||
});
|
||||
|
||||
const generateForObject = async (
|
||||
token: string,
|
||||
objectMetadataItems: ObjectMetadataItem[],
|
||||
objectMetadataItems: EnrichedObjectMetadataItem[],
|
||||
objectNameSingular: string,
|
||||
) => {
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
|
||||