introduce metadata api client to twenty sdk (#18233)

Logic function: hello-world.ts
```typescript
import { CoreApiClient } from 'twenty-sdk/generated/core';
import { MetadataApiClient } from 'twenty-sdk/generated/metadata';

const handler = async () => {
  const coreClient = new CoreApiClient();
  const metadataClient = new MetadataApiClient();

  // Query the core /graphql endpoint — fetch some people
  const coreResult = await coreClient.query({
    people: {
      edges: {
        node: {
          id: true,
          name: {
            firstName: true,
            lastName: true,
          },
        },
      },
    },
  });

  // Query the metadata /metadata endpoint — fetch current workspace
  const metadataResult = await metadataClient.query({
    currentWorkspace: {
      id: true,
      displayName: true,
    },
  });

  return {
    coreResponse: coreResult,
    metadataResponse: metadataResult,
  };
};
```
With route trigger should now produce:
<img width="582" height="238" alt="Screenshot 2026-02-25 at 17 14 29"
src="https://github.com/user-attachments/assets/8c597113-7552-4d32-845a-352083d84ac7"
/>

```json
{
  "coreResponse": {
    "people": {
      "edges": [
        {
          "node": {
            "id": "20202020-b000-4485-94de-70c2a98daef2",
            "name": {
              "firstName": "Jeffery",
              "lastName": "Griffin"
            }
          }
        },
        {
          "node": {
            "id": "20202020-b003-415a-9051-133248495f7f",
            "name": {
              "firstName": "Terry",
              "lastName": "Melendez"
            }
          }
        },
        {
          "node": {
            "id": "20202020-b00e-4bc1-87c8-00aeb49c10f8",
            "name": {
              "firstName": "Lee",
              "lastName": "Jones"
            }
          }
        },
        {
          "node": {
            "id": "20202020-b012-44c1-9fdc-90f110962d07",
            "name": {
              "firstName": "Sarah",
              "lastName": "Hernandez"
            }
          }
        },
      ]
    }
  },
...
  "metadataResponse": {
    "currentWorkspace": {
      "id": "20202020-1c25-4d02-bf25-6aeccf7ea419",
      "displayName": "Apple"
    }
  }
}
This commit is contained in:
Weiko
2026-02-25 21:42:05 +01:00
committed by GitHub
parent e01b641a05
commit 0af980a783
8 changed files with 210 additions and 118 deletions
+2 -2
View File
@@ -41,7 +41,7 @@ yarn twenty auth:login
yarn twenty entity:add
# Start dev mode: watches, builds, and syncs local changes to your workspace
# (also auto-generates a typed API client in node_modules/twenty-sdk/generated)
# (also auto-generates typed API clients — CoreApiClient and MetadataApiClient — in node_modules/twenty-sdk/generated)
yarn twenty app:dev
# Watch your application's function logs
@@ -110,7 +110,7 @@ In interactive mode, you can pick from:
- Use `yarn twenty auth:login` to authenticate with your Twenty workspace.
- Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
- Use `yarn twenty app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- Types are autogenerated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`.
- Two typed API clients are autogenerated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
## Publish your application
@@ -163,7 +163,7 @@ export default defineObject({
Later commands will add more files and folders:
- `yarn twenty app:dev` will auto-generate a typed API client in `node_modules/twenty-sdk/generated` (typed Twenty client + workspace types).
- `yarn twenty app:dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
## Authentication
@@ -423,10 +423,10 @@ Each function file uses `defineLogicFunction()` to export a configuration with a
// src/app/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
import Twenty, { type Person } from '~/generated';
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
const handler = async (params: RoutePayload) => {
const client = new Twenty(); // generated typed client
const client = new CoreApiClient();
const name = 'name' in params.queryStringParameters
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
@@ -635,10 +635,10 @@ To mark a logic function as a tool, set `isTool: true` and provide a `toolInputS
```typescript
// src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import Twenty from '~/generated';
import { CoreApiClient } from 'twenty-sdk/generated';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new Twenty();
const client = new CoreApiClient();
const result = await client.mutation({
createTask: {
@@ -761,18 +761,24 @@ You can create new skills in two ways:
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill.
- **Manual**: Create a new file and use `defineSkill()`, following the same pattern.
### Generated typed client
### Generated typed clients
The typed client is auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema. Use it in your functions:
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema:
- **`CoreApiClient`** — queries the `/graphql` endpoint for workspace data
- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads
```typescript
import Twenty from '~/generated';
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
const client = new Twenty();
const client = new CoreApiClient();
const { me } = await client.query({ me: { id: true, displayName: true } });
const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
```
The client is re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
Both clients are re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
#### Runtime credentials in logic functions
@@ -788,17 +794,17 @@ Notes:
#### Uploading files
The generated `Twenty` client includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
The generated `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
```typescript
import Twenty from '~/generated';
import { MetadataApiClient } from 'twenty-sdk/generated';
import * as fs from 'fs';
const client = new Twenty();
const metadataClient = new MetadataApiClient();
const fileBuffer = fs.readFileSync('./invoice.pdf');
const uploadedFile = await client.uploadFile(
const uploadedFile = await metadataClient.uploadFile(
fileBuffer, // file contents as a Buffer
'invoice.pdf', // filename
'application/pdf', // MIME type (defaults to 'application/octet-stream')
@@ -828,7 +834,7 @@ uploadFile(
| `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object |
Key points:
- The method sends the file to the **metadata endpoint** (not the main GraphQL endpoint), where the upload mutation is resolved.
- The `uploadFile` method is available on `MetadataApiClient` because the upload mutation is resolved by the `/metadata` endpoint.
- It uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed — consistent with how apps reference fields everywhere else.
- The returned `url` is a signed URL you can use to access the uploaded file.
+1 -1
View File
@@ -14,7 +14,7 @@
A CLI and SDK to develop, build, and publish applications that extend [Twenty CRM](https://twenty.com).
- Typesafe client and workspace entity typings
- Two autogenerated typed GraphQL clients: `CoreApiClient` (workspace data) and `MetadataApiClient` (workspace configuration & file uploads)
- Builtin CLI for auth, dev mode (watch & sync), uninstall, and function management
- Works great with the scaffolder: [create-twenty-app](https://www.npmjs.com/package/create-twenty-app)
+16
View File
@@ -44,6 +44,16 @@
"types": "./generated/index.ts",
"import": "./generated/index.ts",
"require": "./generated/index.ts"
},
"./generated/core": {
"types": "./generated/core/index.ts",
"import": "./generated/core/index.ts",
"require": "./generated/core/index.ts"
},
"./generated/metadata": {
"types": "./generated/metadata/index.ts",
"import": "./generated/metadata/index.ts",
"require": "./generated/metadata/index.ts"
}
},
"license": "AGPL-3.0",
@@ -118,6 +128,12 @@
],
"generated": [
"generated/index.ts"
],
"generated/core": [
"generated/core/index.ts"
],
"generated/metadata": [
"generated/metadata/index.ts"
]
}
}
@@ -434,6 +434,19 @@ export class ApiService {
async getSchema(options?: {
authToken?: string;
}): Promise<ApiResponse<string>> {
return this.introspectEndpoint('/graphql', options);
}
async getMetadataSchema(options?: {
authToken?: string;
}): Promise<ApiResponse<string>> {
return this.introspectEndpoint('/metadata', options);
}
private async introspectEndpoint(
endpoint: string,
options?: { authToken?: string },
): Promise<ApiResponse<string>> {
try {
const introspectionQuery = getIntrospectionQuery();
@@ -447,7 +460,7 @@ export class ApiService {
}
const response = await this.client.post(
'/graphql',
endpoint,
{
query: introspectionQuery,
},
@@ -466,7 +479,7 @@ export class ApiService {
return {
success: true,
data: printSchema(schema),
message: 'Successfully load schema',
message: `Successfully loaded schema from ${endpoint}`,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
@@ -474,7 +487,7 @@ export class ApiService {
success: false,
error:
error.response.data.errors[0]?.message ||
'Failed to load graphql Schema',
`Failed to load schema from ${endpoint}`,
};
}
throw error;
@@ -24,7 +24,6 @@ import { ClientService } from '@/cli/utilities/client/client-service';
type TwentyClassType = new (options?: {
url?: string;
metadataUrl?: string;
fetch?: typeof globalThis.fetch;
}) => {
query: (request: Record<string, unknown>) => Promise<unknown>;
@@ -49,7 +48,6 @@ export type GraphqlOperation = Record<string, unknown>
export type ClientOptions = {
url?: string
metadataUrl?: string
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>)
fetcher?: (operation: GraphqlOperation | GraphqlOperation[]) => Promise<unknown>
fetch?: typeof globalThis.fetch
@@ -128,9 +126,20 @@ describe('ClientService generated Twenty auth behavior', () => {
const clientService = new ClientService();
await (
clientService as unknown as {
injectTwentyClient: (output: string) => Promise<void>;
injectClientWrapper: (
output: string,
options: {
className: string;
defaultUrl: string;
includeUploadFile: boolean;
},
) => Promise<void>;
}
).injectTwentyClient(temporaryGeneratedClientDirectory);
).injectClientWrapper(temporaryGeneratedClientDirectory, {
className: 'MetadataApiClient',
defaultUrl: '`${process.env.TWENTY_API_URL}/metadata`',
includeUploadFile: true,
});
const generatedIndexContent = await readFile(
temporaryGeneratedIndexTsPath,
@@ -153,7 +162,7 @@ describe('ClientService generated Twenty auth behavior', () => {
`${pathToFileURL(temporaryGeneratedIndexMjsPath).href}?t=${Date.now()}`
);
TwentyClass = generatedModule.default as TwentyClassType;
TwentyClass = generatedModule.MetadataApiClient as TwentyClassType;
});
beforeEach(() => {
@@ -379,7 +388,6 @@ describe('ClientService generated Twenty auth behavior', () => {
const twentyClient = new TwentyClass({
url: 'https://example.com/graphql',
metadataUrl: 'https://example.com/metadata',
fetch: fetchMock as unknown as typeof globalThis.fetch,
});
@@ -3,12 +3,24 @@ import { generate } from '@genql/cli';
import * as fs from 'fs-extra';
import { join } from 'path';
import {
DEFAULT_APP_ACCESS_TOKEN_NAME,
DEFAULT_API_KEY_NAME,
DEFAULT_API_URL_NAME,
DEFAULT_APP_ACCESS_TOKEN_NAME,
GENERATED_DIR,
} from 'twenty-shared/application';
type ClientWrapperOptions = {
className: string;
defaultUrl: string;
includeUploadFile: boolean;
};
const COMMON_SCALAR_TYPES = {
DateTime: 'string',
JSON: 'Record<string, unknown>',
UUID: 'string',
};
export class ClientService {
private apiService: ApiService;
@@ -26,30 +38,55 @@ export class ClientService {
const outputPath = this.resolveGeneratedPath(appPath);
const tempPath = `${outputPath}.tmp`;
const getSchemaResponse = await this.apiService.getSchema({ authToken });
const [coreSchemaResponse, metadataSchemaResponse] = await Promise.all([
this.apiService.getSchema({ authToken }),
this.apiService.getMetadataSchema({ authToken }),
]);
if (!getSchemaResponse.success) {
if (!coreSchemaResponse.success) {
throw new Error(
`Failed to introspect schema: ${JSON.stringify(getSchemaResponse.error)}`,
`Failed to introspect core schema: ${JSON.stringify(coreSchemaResponse.error)}`,
);
}
const { data: schema } = getSchemaResponse;
if (!metadataSchemaResponse.success) {
throw new Error(
`Failed to introspect metadata schema: ${JSON.stringify(metadataSchemaResponse.error)}`,
);
}
await fs.ensureDir(tempPath);
await fs.emptyDir(tempPath);
await generate({
schema,
output: tempPath,
scalarTypes: {
DateTime: 'string',
JSON: 'Record<string, unknown>',
UUID: 'string',
},
await Promise.all([
generate({
schema: coreSchemaResponse.data,
output: join(tempPath, 'core'),
scalarTypes: COMMON_SCALAR_TYPES,
}),
generate({
schema: metadataSchemaResponse.data,
output: join(tempPath, 'metadata'),
scalarTypes: {
...COMMON_SCALAR_TYPES,
Upload: 'File',
},
}),
]);
await this.injectClientWrapper(join(tempPath, 'core'), {
className: 'CoreApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\``,
includeUploadFile: false,
});
await this.injectTwentyClient(tempPath);
await this.injectClientWrapper(join(tempPath, 'metadata'), {
className: 'MetadataApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\``,
includeUploadFile: true,
});
await this.writeBarrelIndex(tempPath);
await fs.remove(outputPath);
await fs.move(tempPath, outputPath);
@@ -59,19 +96,89 @@ export class ClientService {
return join(appPath, 'node_modules', 'twenty-sdk', GENERATED_DIR);
}
private async injectTwentyClient(output: string) {
const twentyClientContent = `
private async writeBarrelIndex(outputDir: string): Promise<void> {
const barrelContent = `export { CoreApiClient } from './core/index';
export { MetadataApiClient } from './metadata/index';
`;
await fs.writeFile(join(outputDir, 'index.ts'), barrelContent);
}
private async injectClientWrapper(
output: string,
options: ClientWrapperOptions,
): Promise<void> {
const clientContent = this.buildClientWrapperTemplate(options);
await fs.appendFile(join(output, 'index.ts'), clientContent);
}
private buildClientWrapperTemplate(options: ClientWrapperOptions): string {
const { className, defaultUrl, includeUploadFile } = options;
const uploadFileMethod = includeUploadFile
? `
async uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string = 'application/octet-stream',
fieldMetadataUniversalIdentifier: string,
): Promise<{
id: string;
path: string;
size: number;
createdAt: string;
url: string;
}> {
const form = new FormData();
form.append(
'operations',
JSON.stringify({
query: \`mutation UploadFilesFieldFileByUniversalIdentifier($file: Upload!, $fieldMetadataUniversalIdentifier: String!) {
uploadFilesFieldFileByUniversalIdentifier(file: $file, fieldMetadataUniversalIdentifier: $fieldMetadataUniversalIdentifier) { id path size createdAt url }
}\`,
variables: { file: null, fieldMetadataUniversalIdentifier },
}),
);
form.append('map', JSON.stringify({ '0': ['variables.file'] }));
form.append('0', new Blob([fileBuffer], { type: contentType }), filename);
const result = await this.executeGraphqlRequestWithOptionalRefresh({
operation: form,
headers: {},
requestInit: {
method: 'POST',
},
});
if (result.errors) {
throw new GenqlError(result.errors, result.data);
}
const data = result.data as Record<string, unknown>;
return data.uploadFilesFieldFileByUniversalIdentifier as {
id: string;
path: string;
size: number;
createdAt: string;
url: string;
}
}
`
: '';
return `
// ----------------------------------------------------
// Custom Twenty client (auto-injected)
// ${className} (auto-injected)
// ----------------------------------------------------
const APP_ACCESS_TOKEN_ENV_KEY = '${DEFAULT_APP_ACCESS_TOKEN_NAME}';
const API_KEY_ENV_KEY = '${DEFAULT_API_KEY_NAME}';
type TwentyClientOptions = ClientOptions & {
metadataUrl?: string;
}
type ${className}Options = ClientOptions
type ProcessEnvironment = Record<string, string | undefined>
@@ -157,39 +264,35 @@ const hasAuthenticationErrorInGraphqlPayload = (
return payload.errors.some((error) => {
return (
error.extensions?.code === 'UNAUTHENTICATED' ||
// Fallback for payloads that don't provide structured error codes.
error.message?.toLowerCase() === 'unauthorized'
);
});
}
const defaultOptions: TwentyClientOptions = {
url: \`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\`,
metadataUrl: \`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\`,
const defaultOptions: ${className}Options = {
url: ${defaultUrl},
headers: {
'Content-Type': 'application/json',
},
}
export default class Twenty {
export class ${className} {
private client: Client;
private url: string;
private metadataUrl: string;
private requestOptions: RequestInit;
private headers: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
private fetchImplementation: typeof globalThis.fetch | null;
private authorizationToken: string | null;
private refreshAccessTokenPromise: Promise<string | null> | null = null;
constructor(options?: TwentyClientOptions) {
const merged: TwentyClientOptions = {
constructor(options?: ${className}Options) {
const merged: ${className}Options = {
...defaultOptions,
...options,
}
const {
url,
metadataUrl,
headers,
fetch: customFetchImplementation,
fetcher: _fetcher,
@@ -198,7 +301,6 @@ export default class Twenty {
} = merged;
this.url = url ?? '';
this.metadataUrl = metadataUrl ?? this.url.replace(/\\/graphql$/, '/metadata');
this.requestOptions = requestOptions;
this.headers = headers ?? {};
this.fetchImplementation = customFetchImplementation ?? globalThis.fetch ?? null;
@@ -232,71 +334,18 @@ export default class Twenty {
mutation<R extends MutationGenqlSelection>(request: R & { __name?: string }) {
return this.client.mutation(request);
}
async uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string = 'application/octet-stream',
fieldMetadataUniversalIdentifier: string,
): Promise<{
id: string;
path: string;
size: number;
createdAt: string;
url: string;
}> {
const form = new FormData();
form.append(
'operations',
JSON.stringify({
query: \`mutation UploadFilesFieldFileByUniversalIdentifier($file: Upload!, $fieldMetadataUniversalIdentifier: String!) {
uploadFilesFieldFileByUniversalIdentifier(file: $file, fieldMetadataUniversalIdentifier: $fieldMetadataUniversalIdentifier) { id path size createdAt url }
}\`,
variables: { file: null, fieldMetadataUniversalIdentifier },
}),
);
form.append('map', JSON.stringify({ '0': ['variables.file'] }));
form.append('0', new Blob([fileBuffer], { type: contentType }), filename);
const result = await this.executeGraphqlRequestWithOptionalRefresh({
operation: form,
url: this.metadataUrl,
headers: {},
requestInit: {
method: 'POST',
},
});
if (result.errors) {
throw new GenqlError(result.errors, result.data);
}
const data = result.data as Record<string, unknown>;
return data.uploadFilesFieldFileByUniversalIdentifier as {
id: string;
path: string;
size: number;
createdAt: string;
url: string;
}
}
${uploadFileMethod}
private async executeGraphqlRequestWithOptionalRefresh({
operation,
url = this.url,
headers,
requestInit,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
url?: string;
headers?: HeadersInit;
requestInit?: RequestInit;
}) {
const firstResponse = await this.executeGraphqlRequest({
operation,
url,
headers,
requestInit,
token: this.authorizationToken,
@@ -308,7 +357,6 @@ export default class Twenty {
if (refreshedAccessToken) {
const retryResponse = await this.executeGraphqlRequest({
operation,
url,
headers,
requestInit,
token: refreshedAccessToken,
@@ -323,13 +371,11 @@ export default class Twenty {
private async executeGraphqlRequest({
operation,
url,
headers,
requestInit,
token,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
url: string;
headers?: HeadersInit;
requestInit?: RequestInit;
token: string | null;
@@ -359,7 +405,7 @@ export default class Twenty {
requestHeaders.delete('Authorization');
}
const response = await this.fetchImplementation.call(globalThis, url, {
const response = await this.fetchImplementation.call(globalThis, this.url, {
...this.requestOptions,
...requestInit,
method: requestInit?.method ?? 'POST',
@@ -465,7 +511,5 @@ export default class Twenty {
}
`;
await fs.appendFile(join(output, 'index.ts'), twentyClientContent);
}
}
@@ -13,7 +13,12 @@ import {
} from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
const API_CLIENT_FILES = ['types.ts', 'schema.ts'];
const API_CLIENT_FILES = [
'core/types.ts',
'core/schema.ts',
'metadata/types.ts',
'metadata/schema.ts',
];
export type UploadFilesOrchestratorStepOutput = {
fileUploader: FileUploader | null;