Skip to main content
Logic functions are server-side TypeScript functions that run on the Twenty platform. They can be triggered by HTTP requests, cron schedules, or database events — and can also be exposed as tools for AI agents.
Each function file uses defineLogicFunction() to export a configuration with a handler and optional triggers.
src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';

const handler = async (params: RoutePayload) => {
  const client = new CoreApiClient();
  const name = 'name' in params.queryStringParameters
    ? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
    : 'Hello world';

  const result = await client.mutation({
    createPostCard: {
      __args: { data: { name } },
      id: true,
      name: true,
    },
  });
  return result;
};

export default defineLogicFunction({
  universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
  name: 'create-new-post-card',
  timeoutSeconds: 2,
  handler,
  httpRouteTriggerSettings: {
    path: '/post-card/create',
    httpMethod: 'GET',
    isAuthRequired: true,
  },
  /*databaseEventTriggerSettings: {
    eventName: 'people.created',
  },*/
  /*cronTriggerSettings: {
    pattern: '0 0 1 1 *',
  },*/
});
Available trigger types:
  • httpRoute: Exposes your function on an HTTP path and method under the /s/ endpoint:
e.g. path: '/post-card/create' is callable at https://your-twenty-server.com/s/post-card/create
  • cron: Runs your function on a schedule using a CRON expression.
  • databaseEvent: Runs on workspace object lifecycle events. When the event operation is updated, specific fields to listen to can be specified in the updatedFields array. If left undefined or empty, any update will trigger the function.
e.g. person.updated, *.created, company.*
You can also manually execute a function using the CLI:
yarn twenty exec -n create-new-post-card -p '{"key": "value"}'
yarn twenty exec -y e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
You can watch logs with:
yarn twenty logs

Route trigger payload

When a route trigger invokes your logic function, it receives a RoutePayload object that follows the AWS HTTP API v2 format. Import the RoutePayload type from twenty-sdk:
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';

const handler = async (event: RoutePayload) => {
  const { headers, queryStringParameters, pathParameters, body } = event;
  const { method, path } = event.requestContext.http;

  return { message: 'Success' };
};
The RoutePayload type has the following structure:
PropertyTypeDescriptionExample
headersRecord<string, string | undefined>HTTP headers (only those listed in forwardedRequestHeaders)see section below
queryStringParametersRecord<string, string | undefined>Query string parameters (multiple values joined with commas)/users?ids=1&ids=2&ids=3&name=Alice -> { ids: '1,2,3', name: 'Alice' }
pathParametersRecord<string, string | undefined>Path parameters extracted from the route pattern/users/:id, /users/123 -> { id: '123' }
bodyobject | nullParsed request body (JSON){ id: 1 } -> { id: 1 }
isBase64EncodedbooleanWhether the body is base64 encoded
requestContext.http.methodstringHTTP method (GET, POST, PUT, PATCH, DELETE)
requestContext.http.pathstringRaw request path

forwardedRequestHeaders

By default, HTTP headers from incoming requests are not passed to your logic function for security reasons. To access specific headers, list them in the forwardedRequestHeaders array:
export default defineLogicFunction({
  universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
  name: 'webhook-handler',
  handler,
  httpRouteTriggerSettings: {
    path: '/webhook',
    httpMethod: 'POST',
    isAuthRequired: false,
    forwardedRequestHeaders: ['x-webhook-signature', 'content-type'],
  },
});
In your handler, access the forwarded headers like this:
const handler = async (event: RoutePayload) => {
  const signature = event.headers['x-webhook-signature'];
  const contentType = event.headers['content-type'];

  // Validate webhook signature...
  return { received: true };
};
Header names are normalized to lowercase. Access them using lowercase keys (e.g., event.headers['content-type']).

Exposing a function as a tool

Logic functions can be exposed as tools for AI agents and workflows. When marked as a tool, a function becomes discoverable by Twenty’s AI features and can be used in workflow automations.To mark a logic function as a tool, set isTool: true:
src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';

const handler = async (params: { companyName: string; domain?: string }) => {
  const client = new CoreApiClient();

  const result = await client.mutation({
    createTask: {
      __args: {
        data: {
          title: `Enrich data for ${params.companyName}`,
          body: `Domain: ${params.domain ?? 'unknown'}`,
        },
      },
      id: true,
    },
  });

  return { taskId: result.createTask.id };
};

export default defineLogicFunction({
  universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
  name: 'enrich-company',
  description: 'Enrich a company record with external data',
  timeoutSeconds: 10,
  handler,
  isTool: true,
});
Key points:
  • You can combine isTool with triggers — a function can be both a tool (callable by AI agents) and triggered by events at the same time.
  • toolInputSchema (optional): A JSON Schema object describing the parameters your function accepts. The schema is computed automatically from source code static analysis, but you can set it explicitly:
export default defineLogicFunction({
  ...,
  toolInputSchema: {
    type: 'object',
    properties: {
      companyName: {
        type: 'string',
        description: 'The name of the company to enrich',
      },
      domain: {
        type: 'string',
        description: 'The company website domain (optional)',
      },
    },
    required: ['companyName'],
  },
});
Write a good description. AI agents rely on the function’s description field to decide when to use the tool. Be specific about what the tool does and when it should be called.
A post-install function is a logic function that runs automatically once your app has finished installing on a workspace. The server executes it after the app’s metadata has been synchronized and the SDK client has been generated, so the workspace is fully ready to use and the new schema is in place. Typical use cases include seeding default data, creating initial records, configuring workspace settings, or provisioning resources on third-party services.
src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';

const handler = async (payload: InstallPayload): Promise<void> => {
  console.log('Post install logic function executed successfully!', payload.previousVersion);
};

export default definePostInstallLogicFunction({
  universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210',
  name: 'post-install',
  description: 'Runs after installation to set up the application.',
  timeoutSeconds: 300,
  shouldRunOnVersionUpgrade: false,
  shouldRunSynchronously: false,
  handler,
});
You can also manually execute the post-install function at any time using the CLI:
yarn twenty exec --postInstall
Key points:
  • Post-install functions use definePostInstallLogicFunction() — a specialized variant that omits trigger settings (cronTriggerSettings, databaseEventTriggerSettings, httpRouteTriggerSettings, isTool).
  • The handler receives an InstallPayload with { previousVersion?: string; newVersion: string }newVersion is the version being installed, and previousVersion is the version that was previously installed (or undefined on a fresh install). Use these values to distinguish fresh installs from upgrades and to run version-specific migration logic.
  • When the hook runs: on fresh installs only, by default. Pass shouldRunOnVersionUpgrade: true if you also want it to run when the app is upgraded from a previous version. When omitted, the flag defaults to false and upgrades skip the hook.
  • Execution model — async by default, sync opt-in: the shouldRunSynchronously flag controls how post-install is executed.
    • shouldRunSynchronously: false (default) — the hook is enqueued on the message queue with retryLimit: 3 and runs asynchronously in a worker. The install response returns as soon as the job is enqueued, so a slow or failing handler does not block the caller. The worker will retry up to three times. Use this for long-running jobs — seeding large datasets, calling slow third-party APIs, provisioning external resources, anything that might exceed a reasonable HTTP response window.
    • shouldRunSynchronously: true — the hook is executed inline during the install flow (same executor as pre-install). The install request blocks until the handler finishes, and if it throws, the install caller receives a POST_INSTALL_ERROR. No automatic retries. Use this for fast, must-complete-before-response work — for example, emitting a validation error to the user, or quick setup that the client will rely on immediately after the install call returns. Keep in mind the metadata migration has already been applied by the time post-install runs, so a sync-mode failure does not roll back the schema changes — it only surfaces the error.
  • Make sure your handler is idempotent. In async mode the queue may retry up to three times; in either mode the hook may run again on upgrades when shouldRunOnVersionUpgrade: true.
  • The environment variables APPLICATION_ID, APP_ACCESS_TOKEN, and API_URL are available inside the handler (same as any other logic function), so you can call the Twenty API with an application access token scoped to your app.
  • Only one post-install function is allowed per application. The manifest build will error if more than one is detected.
  • The function’s universalIdentifier, shouldRunOnVersionUpgrade, and shouldRunSynchronously are automatically attached to the application manifest under the postInstallLogicFunction field during the build — you do not need to reference them in defineApplication().
  • The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
  • Not executed in dev mode: when an app is registered locally (via yarn twenty dev), the server skips the install flow entirely and syncs files directly through the CLI watcher — so post-install never runs in dev mode, regardless of shouldRunSynchronously. Use yarn twenty exec --postInstall to trigger it manually against a running workspace.
A pre-install function is a logic function that runs automatically during installation, before the workspace metadata migration is applied. It shares the same payload shape as post-install (InstallPayload), but it is positioned earlier in the install flow so it can prepare state that the upcoming migration depends on — typical uses include backing up data, validating compatibility with the new schema, or archiving records that are about to be restructured or dropped.
src/logic-functions/pre-install.ts
import { definePreInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';

const handler = async (payload: InstallPayload): Promise<void> => {
  console.log('Pre install logic function executed successfully!', payload.previousVersion);
};

export default definePreInstallLogicFunction({
  universalIdentifier: 'a1b2c3d4-5678-90ab-cdef-1234567890ab',
  name: 'pre-install',
  description: 'Runs before installation to prepare the application.',
  timeoutSeconds: 300,
  shouldRunOnVersionUpgrade: true,
  handler,
});
You can also manually execute the pre-install function at any time using the CLI:
yarn twenty exec --preInstall
Key points:
  • Pre-install functions use definePreInstallLogicFunction() — same specialized config as post-install, just attached to a different lifecycle slot.
  • Both pre- and post-install handlers receive the same InstallPayload type: { previousVersion?: string; newVersion: string }. Import it once and reuse it for both hooks.
  • When the hook runs: positioned just before the workspace metadata migration (synchronizeFromManifest). Before executing, the server runs a purely additive “pared-down sync” that registers the new version’s pre-install function in the workspace metadata — nothing else is touched — and then executes it. Because this sync is additive-only, the previous version’s objects, fields, and data are still intact when your handler runs: you can safely read and back up pre-migration state.
  • Execution model: pre-install is executed synchronously and blocks the install. If the handler throws, the install is aborted before any schema changes are applied — the workspace stays on the previous version in a consistent state. This is intentional: pre-install is your last chance to refuse a risky upgrade.
  • As with post-install, only one pre-install function is allowed per application. It is attached to the application manifest under preInstallLogicFunction automatically during the build.
  • Not executed in dev mode: same as post-install — the install flow is skipped entirely for locally-registered apps, so pre-install never runs under yarn twenty dev. Use yarn twenty exec --preInstall to trigger it manually.
Both hooks are part of the same install flow and receive the same InstallPayload. The difference is when they run relative to the workspace metadata migration, and that changes what data they can safely touch.
┌─────────────────────────────────────────────────────────────┐
│ install flow                                                │
│                                                             │
│   upload package → [pre-install] → metadata migration →     │
│   generate SDK → [post-install]                             │
│                                                             │
│                  old schema visible    new schema visible   │
└─────────────────────────────────────────────────────────────┘
Pre-install is always synchronous (it blocks the install and can abort it). Post-install is asynchronous by default — enqueued on a worker with automatic retries — but can opt into synchronous execution with shouldRunSynchronously: true. See the definePostInstallLogicFunction accordion above for when to use each mode.Use post-install for anything that needs the new schema to exist. This is the common case:
  • Seeding default data (creating initial records, default views, demo content) against newly-added objects and fields.
  • Registering webhooks with third-party services now that the app has its credentials.
  • Calling your own API to finish setup that depends on the synchronized metadata.
  • Idempotent “ensure this exists” logic that should reconcile state on every upgrade — combine with shouldRunOnVersionUpgrade: true.
Example — seed a default PostCard record after install:
src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import { createClient } from './generated/client';

const handler = async ({ previousVersion }: InstallPayload): Promise<void> => {
  if (previousVersion) return; // fresh installs only

  const client = createClient();
  await client.postCard.create({
    data: { title: 'Welcome to Postcard', content: 'Your first card!' },
  });
};

export default definePostInstallLogicFunction({
  universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210',
  name: 'post-install',
  description: 'Seeds a welcome post card after install.',
  timeoutSeconds: 300,
  shouldRunOnVersionUpgrade: false,
  handler,
});
Use pre-install when a migration would otherwise destroy or corrupt existing data. Because pre-install runs against the previous schema and its failure rolls back the upgrade, it is the right place for anything risky:
  • Backing up data that is about to be dropped or restructured — e.g. you are removing a field in v2 and need to copy its values into another field or export them to storage before the migration runs.
  • Archiving records that a new constraint would invalidate — e.g. a field is becoming NOT NULL and you need to delete or fix rows with null values first.
  • Validating compatibility and refusing the upgrade if the current data cannot be migrated cleanly — throw from the handler and the install aborts with no changes applied. This is safer than discovering the incompatibility mid-migration.
  • Renaming or rekeying data ahead of a schema change that would lose the association.
Example — archive records before a destructive migration:
src/logic-functions/pre-install.ts
import { definePreInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import { createClient } from './generated/client';

const handler = async ({ previousVersion, newVersion }: InstallPayload): Promise<void> => {
  // Only the 1.x → 2.x upgrade drops the legacy `notes` field.
  if (!previousVersion?.startsWith('1.') || !newVersion.startsWith('2.')) {
    return;
  }

  const client = createClient();
  const legacyRecords = await client.postCard.findMany({
    where: { notes: { isNotNull: true } },
  });

  if (legacyRecords.length === 0) return;

  // Copy legacy `notes` into the new `description` field before the migration
  // drops the `notes` column. If this fails, the upgrade is aborted and the
  // workspace stays on v1 with all data intact.
  await Promise.all(
    legacyRecords.map((record) =>
      client.postCard.update({
        where: { id: record.id },
        data: { description: record.notes },
      }),
    ),
  );
};

export default definePreInstallLogicFunction({
  universalIdentifier: 'a1b2c3d4-5678-90ab-cdef-1234567890ab',
  name: 'pre-install',
  description: 'Backs up legacy notes into description before the v2 migration.',
  timeoutSeconds: 300,
  shouldRunOnVersionUpgrade: true,
  handler,
});
Rule of thumb:
You want to…Use
Seed default data, configure the workspace, register external resourcespost-install
Run long-running seeding or third-party calls that shouldn’t block the install responsepost-install (default — shouldRunSynchronously: false, with worker retries)
Run fast setup that the caller will rely on immediately after the install call returnspost-install with shouldRunSynchronously: true
Read or back up data that the upcoming migration would losepre-install
Reject an upgrade that would corrupt existing datapre-install (throw from the handler)
Run reconciliation on every upgradepost-install with shouldRunOnVersionUpgrade: true
Do one-off setup on the first install onlypost-install with shouldRunOnVersionUpgrade: false (default)
If in doubt, default to post-install. Only reach for pre-install when the migration itself is destructive and you need to intercept the previous state before it is gone.

Typed API clients (twenty-client-sdk)

The twenty-client-sdk package provides two typed GraphQL clients for interacting with the Twenty API from your logic functions and front components.
ClientImportEndpointGenerated?
CoreApiClienttwenty-client-sdk/core/graphql — workspace data (records, objects)Yes, at dev/build time
MetadataApiClienttwenty-client-sdk/metadata/metadata — workspace config, file uploadsNo, ships pre-built
CoreApiClient is the main client for querying and mutating workspace data. It is generated from your workspace schema during yarn twenty dev or yarn twenty build, so it is fully typed to match your objects and fields.
import { CoreApiClient } from 'twenty-client-sdk/core';

const client = new CoreApiClient();

// Query records
const { companies } = await client.query({
  companies: {
    edges: {
      node: {
        id: true,
        name: true,
        domainName: {
          primaryLinkLabel: true,
          primaryLinkUrl: true,
        },
      },
    },
  },
});

// Create a record
const { createCompany } = await client.mutation({
  createCompany: {
    __args: {
      data: {
        name: 'Acme Corp',
      },
    },
    id: true,
    name: true,
  },
});
The client uses a selection-set syntax: pass true to include a field, use __args for arguments, and nest objects for relations. You get full autocompletion and type checking based on your workspace schema.
CoreApiClient is generated at dev/build time. If you use it without running yarn twenty dev or yarn twenty build first, it throws an error. The generation happens automatically — the CLI introspects your workspace’s GraphQL schema and generates a typed client using @genql/cli.

Using CoreSchema for type annotations

CoreSchema provides TypeScript types matching your workspace objects — useful for typing component state or function parameters:
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { useState } from 'react';

const [company, setCompany] = useState<
  Pick<CoreSchema.Company, 'id' | 'name'> | undefined
>(undefined);

const client = new CoreApiClient();
const result = await client.query({
  company: {
    __args: { filter: { position: { eq: 1 } } },
    id: true,
    name: true,
  },
});
setCompany(result.company);
MetadataApiClient ships pre-built with the SDK (no generation required). It queries the /metadata endpoint for workspace configuration, applications, and file uploads.
import { MetadataApiClient } from 'twenty-client-sdk/metadata';

const metadataClient = new MetadataApiClient();

// List first 10 objects in the workspace
const { objects } = await metadataClient.query({
  objects: {
    edges: {
      node: {
        id: true,
        nameSingular: true,
        namePlural: true,
        labelSingular: true,
        isCustom: true,
      },
    },
    __args: {
      filter: {},
      paging: { first: 10 },
    },
  },
});

Uploading files

MetadataApiClient includes an uploadFile method for attaching files to file-type fields:
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import * as fs from 'fs';

const metadataClient = new MetadataApiClient();

const fileBuffer = fs.readFileSync('./invoice.pdf');

const uploadedFile = await metadataClient.uploadFile(
  fileBuffer,                                         // file contents as a Buffer
  'invoice.pdf',                                      // filename
  'application/pdf',                                  // MIME type
  '58a0a314-d7ea-4865-9850-7fb84e72f30b',            // field universalIdentifier
);

console.log(uploadedFile);
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
ParameterTypeDescription
fileBufferBufferThe raw file contents
filenamestringThe name of the file (used for storage and display)
contentTypestringMIME type (defaults to application/octet-stream if omitted)
fieldMetadataUniversalIdentifierstringThe universalIdentifier of the file-type field on your object
Key points:
  • Uses the field’s universalIdentifier (not its workspace-specific ID), so your upload code works across any workspace where your app is installed.
  • The returned url is a signed URL you can use to access the uploaded file.
When your code runs on Twenty (logic functions or front components), the platform injects credentials as environment variables:
  • TWENTY_API_URL — Base URL of the Twenty API
  • TWENTY_APP_ACCESS_TOKEN — Short-lived key scoped to your application’s default function role
You do not need to pass these to the clients — they read from process.env automatically. The API key’s permissions are determined by the role referenced in defaultRoleUniversalIdentifier in your application-config.ts.