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 { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';

const handler = async (params: RoutePayload) => {
  const client = new CoreApiClient();
  const body = (params.body ?? {}) as { name?: string };
  const name = body.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? '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: 'POST',
    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
To invoke a route-triggered logic function from a (headless) front component, see Calling a logic function.
  • 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.*
  • serverWebhook: Receives inbound webhooks from a third-party service (Stripe, GitHub, Svix, …) at a single registration-scoped endpoint and resolves the target workspace from the payload. See Server webhook trigger.
You can also manually execute a function using the CLI:
yarn twenty dev:function:exec -n create-new-post-card -p '{"key": "value"}'
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
You can watch logs with:
yarn twenty dev:function: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/logic-function:
import type { RoutePayload } from 'twenty-sdk/logic-function';

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 }
rawBodystring | undefinedOriginal UTF-8 request body, before JSON parsing. Useful for verifying HMAC-style webhook signatures (e.g. GitHub’s X-Hub-Signature-256, Stripe). undefined when the runtime did not preserve it.
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']).

Custom HTTP response

By default, returning a plain value from your handler sends it back as a 200 response (JSON for objects, text/plain for strings). To control the status code and response headers, return a Response from twenty-sdk/logic-function:
import { Response } from 'twenty-sdk/logic-function';

const handler = async (event: RoutePayload) => {
  return new Response('<h1>Hello</h1>', {
    status: 201,
    headers: { 'content-type': 'text/html' },
  });
};
For security reasons, response headers are restricted to an allow-list. Any header that is not on the list (e.g. Set-Cookie, CORS headers such as Access-Control-Allow-Origin, or custom X-* headers) is silently dropped before the response is sent. The allowed response headers are:
  • content-type
  • content-language
  • content-disposition
  • cache-control
  • retry-after
The status code must be a valid HTTP status code (between 100 and 599). Response header names are matched case-insensitively.

Server webhook trigger

httpRouteTriggerSettings exposes a function under /s/ and resolves the workspace from the request host — which works when each workspace has its own domain. Third-party providers, however, deliver every tenant’s events to one webhook URL. For that case, use serverWebhookTriggerSettings: the function is reachable at a registration-scoped endpoint and the workspace is resolved from the payload.
src/logic-functions/handle-provider-webhook.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { Response } from 'twenty-sdk/logic-function';

const handler = async (event: RoutePayload) => {
  // Verify the signature yourself before doing anything (see below).
  // Return a non-2xx Response to make the provider retry.
  return { received: true };
};

export default defineLogicFunction({
  universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
  name: 'handle-provider-webhook',
  handler,
  serverWebhookTriggerSettings: {
    workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
    forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
  },
});
The function is reachable at:
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
Both identifiers are the universalIdentifiers from your manifest — the application registration’s and this logic function’s. Register that URL with the provider.Workspace resolution. Because one endpoint serves every workspace, your integration must put the target workspaceId somewhere in the delivery, and workspaceIdResolver.{ source, path } tells the platform where to read it:
FieldValuesNotes
sourcebody | query | headerbody reads the parsed JSON. query is the most universal — you usually control the callback URL you register, so append ?twentyWorkspaceId=….
pathdot-path, e.g. metadata.twentyWorkspaceIdRestricted to alphanumeric / _ / - segments; prototype keys are rejected.
The resolved value must be a valid workspace UUID and your app must be installed in that workspace, otherwise the request is rejected before the function runs.
Signature verification is your responsibility. The platform does not verify webhook signatures for this trigger — it only resolves the workspace and runs your function. Your handler must verify the signature itself using event.rawBody and the headers you listed in forwardedRequestHeaders, comparing against a secret stored as a server/application variable. Always verify before any side effect, and use a constant-time comparison.
Most providers sign with HMAC-SHA256; the parts that differ are the header name, the digest encoding, and the signed-payload string. A few examples:
ProviderHeaders to forwardSigned stringDigest
Svix (Recall, Resend, Clerk)webhook-id, webhook-timestamp, webhook-signature{id}.{timestamp}.{rawBody}base64 (secret is base64 after stripping whsec_)
Stripestripe-signature{timestamp}.{rawBody}hex
GitHubx-hub-signature-256{rawBody}hex (prefixed sha256=)
Shopifyx-shopify-hmac-sha256{rawBody}base64
Slackx-slack-signature, x-slack-request-timestampv0:{timestamp}:{rawBody}hex (prefixed v0=)
import { createHmac, timingSafeEqual } from 'crypto';

const handler = async (event: RoutePayload) => {
  const signature = event.headers['x-hub-signature-256'] ?? '';
  const expected =
    'sha256=' +
    createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
      .update(event.rawBody ?? '')
      .digest('hex');

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);

  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return new Response({ error: 'invalid signature' }, { status: 401 });
  }

  // ...handle the verified event
  return { received: true };
};
The function runs synchronously and your returned value becomes the HTTP response, so providers see your status code and can retry on non-2xx. Keep handlers fast — some providers (e.g. Slack) time out in a few seconds. Because the function runs before the signature is checked, protect this endpoint with rate limiting at your edge.

Database event trigger payload

When a database event trigger invokes your logic function, it receives one DatabaseEventPayload per changed record. The payload combines metadata about the source workspace and object with the record-level event.
import type {
  DatabaseEventPayload,
  ObjectRecordCreateEvent,
  ObjectRecordDestroyEvent,
  ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';

type Person = {
  id: string;
  emails?: { primaryEmail?: string };
};
The payload includes:
PropertyDescription
nameEvent name, such as person.updated.
workspaceIdWorkspace where the event happened.
objectMetadataMetadata for the object that changed.
recordIdId of the changed record.
userId, userWorkspaceId, workspaceMemberIdActor fields when the event was caused by a workspace user.
propertiesRecord data for the event, with before, after, diff, and updatedFields depending on the operation.
EventRecord data
person.createdevent.properties.after
person.updatedevent.properties.before, event.properties.after, event.properties.diff, event.properties.updatedFields
person.destroyedevent.properties.before
For soft deletes, .deleted follows the update-style shape because the record’s deletedAt field changes. For permanent deletes, use .destroyed.
databaseEventTriggerSettings.updatedFields filters which update events trigger the function. event.properties.updatedFields tells you which fields actually changed on the current event.
Created event example:
type PersonCreatedEvent = DatabaseEventPayload<
  ObjectRecordCreateEvent<Person>
>;

const handler = async (event: PersonCreatedEvent) => {
  const person = event.properties.after;

  return {
    personId: event.recordId,
    email: person.emails?.primaryEmail,
  };
};
Updated event example:
type PersonUpdatedEvent = DatabaseEventPayload<
  ObjectRecordUpdateEvent<Person>
>;

const handler = async (event: PersonUpdatedEvent) => {
  const { before, after, diff, updatedFields } = event.properties;

  return {
    personId: event.recordId,
    updatedFields,
    previousEmail: before.emails?.primaryEmail,
    currentEmail: after.emails?.primaryEmail,
    emailDiff: diff.emails,
  };
};
Trigger only on email updates:
export default defineLogicFunction({
  ...,
  databaseEventTriggerSettings: {
    eventName: 'person.updated',
    updatedFields: ['emails'],
  },
});
Destroyed event example:
type PersonDestroyedEvent = DatabaseEventPayload<
  ObjectRecordDestroyEvent<Person>
>;

const handler = async (event: PersonDestroyedEvent) => {
  const personBeforeDestroy = event.properties.before;

  return {
    personId: event.recordId,
    email: personBeforeDestroy.emails?.primaryEmail,
  };
};

Exposing a function as an AI tool or workflow action

Logic functions can be exposed on two surfaces, each with its own trigger:
  • toolTriggerSettings — makes the function discoverable by Twenty’s AI features (chat, MCP, function calling). Uses standard JSON Schema, the format LLMs natively understand.
  • workflowActionTriggerSettings — makes the function appear as a step in the visual workflow builder. Uses Twenty’s rich InputSchema so the builder can render proper field editors, variable pickers, and labels.
A function can opt into one, the other, or both. They sit alongside cronTriggerSettings, databaseEventTriggerSettings, and httpRouteTriggerSettings — same pattern, same shape.
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,
  toolTriggerSettings: {},
});
Key points:
  • A function can mix surfaces — declare both toolTriggerSettings and workflowActionTriggerSettings to expose it in chat AND in the workflow builder.
  • toolTriggerSettings.inputSchema and workflowActionTriggerSettings.inputSchema are both optional. When omitted, the manifest builder infers them from the handler source code (JSON Schema for the AI tool, Twenty’s InputSchema for the workflow action). Provide one explicitly when you want richer typing — for example, with FieldMetadataType-aware fields like CURRENCY or RELATION for the workflow builder, or with description fields the AI agent can read:
export default defineLogicFunction({
  ...,
  toolTriggerSettings: {
    inputSchema: {
      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'],
    },
  },
});
To declare your parameters once and serve both surfaces, define a single JSON Schema (InputJsonSchema) and convert it for the workflow action with jsonSchemaToInputSchema from twenty-sdk/logic-function. toolTriggerSettings.inputSchema takes the JSON Schema directly, while workflowActionTriggerSettings.inputSchema expects Twenty’s InputSchema:
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function';

const inputSchema: InputJsonSchema = {
  type: 'object',
  properties: {
    companyName: { type: 'string', label: 'Company name' },
    domain: { type: 'string', label: 'Domain' },
  },
  required: ['companyName'],
};

export default defineLogicFunction({
  ...,
  toolTriggerSettings: { inputSchema },
  workflowActionTriggerSettings: {
    label: 'Enrich Company',
    icon: 'IconBuilding',
    inputSchema: jsonSchemaToInputSchema(inputSchema),
  },
});
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.
Runtime helpers. twenty-sdk/utils re-exports small runtime helpers so handlers never import from twenty-shared directly. For example, isDefined(value) returns false for both null and undefined — use it to safely narrow optional handler inputs, which can arrive as null at runtime even when typed T | undefined:
import { isDefined } from 'twenty-sdk/utils';

const handler = async (params: { parentMessageId?: string }) => {
  if (isDefined(params.parentMessageId)) {
    // params.parentMessageId is narrowed to string here
  }
};
Install hooks — pre-install and post-install handlers — share this runtime but are declared with their own define functions and don’t take trigger settings. See Install Hooks for definePreInstallLogicFunction and definePostInstallLogicFunction.

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 dev: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 dev: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 declared with defineApplicationRole() (or referenced via defaultRoleUniversalIdentifier in application-config.ts).