Skip to main content

Overview

Skills and agents are currently in alpha. The feature works but is still evolving.
Apps can define AI capabilities that live inside the workspace — reusable skill instructions and agents with custom system prompts.
Skills define reusable instructions and capabilities that AI agents can use within your workspace. Use defineSkill() to define skills with built-in validation:
src/skills/example-skill.ts
Key points:
  • name is a unique identifier string for the skill (kebab-case recommended).
  • label is the human-readable display name shown in the UI.
  • content contains the skill instructions — this is the text the AI agent uses.
  • icon (optional) sets the icon displayed in the UI.
  • description (optional) provides additional context about the skill’s purpose.
Agents are AI assistants that live inside your workspace. Use defineAgent() to create agents with a custom system prompt:
src/agents/example-agent.ts
Key points:
  • name is the unique identifier string for the agent (kebab-case recommended).
  • label is the display name shown in the UI.
  • prompt is the system prompt that defines the agent’s behavior.
  • description (optional) provides context about what the agent does.
  • icon (optional) sets the icon displayed in the UI.
  • modelId (optional) overrides the default AI model used by the agent.
  • responseFormat (optional) sets the agent’s default output shape. Defaults to { type: 'text' } for free-form text. Use { type: 'json', schema } to force structured JSON output. A runAgent() call or a workflow AI step can ask for a different shape, which takes precedence.
  • roleUniversalIdentifier (optional) binds the agent to a role your app defines. That role must not grant more than the application role, or the build fails; see Agent roles.
  • modelConfiguration (optional) turns on the model’s native tools, as the agent settings page does: { webSearch: { enabled: true }, twitterSearch: { enabled: true } }. Each tool only works on models that support it. Leave it out to keep them off.
  • isSystem (optional, defaults to false) hides the agent from agent lists, like the agents Twenty creates for workflow AI steps. It only changes what is displayed: an app’s agents can only be changed through the app, whatever this flag says.
By default an agent returns free-form text. To get structured output, set responseFormat to { type: 'json' } and provide a schema:
src/agents/structured-agent.ts
Schema notes:
  • The schema is a flat object: each property’s type must be a primitive (string, number, or boolean). Nested objects and arrays are not supported.
  • description (optional) on each property guides the model on what to put there.
  • required (optional) lists the properties the model must always return.
  • additionalProperties: false (optional) forbids any property not declared in properties.
MCP servers are in beta and not live yet: you can declare them, but workspaces cannot use their tools until the feature ships.
Use defineMcpServer() to ship a remote MCP server (Streamable HTTP) with your app, so the workspace’s AI can use its tools without syncing that data into Twenty:
src/mcp-servers/github.ts
Key points:
  • name is snake_case (lowercase letters, digits and underscores, starting with a letter, at most 32 characters) and unique in the workspace. It prefixes the server’s tool names.
  • url must be an HTTP or HTTPS URL.
  • connectionProvider (optional) references a connection provider declared by the same app. Calls send that connection’s access token as a Bearer token. Omit it for a public server.
  • The URL cannot change in a later version while a connection provider is linked, so tokens are never sent to another host.
  • Workspaces can deactivate the server; a later app version keeps that choice.
  • Requires "engines": { "twenty": ">=2.53.0" }.
runAgent() lets a logic function run one of your app’s agents (with its skills and tools). Identify the agent by the universalIdentifier you passed to defineAgent(), and pass what you want it to work on as input:
src/logic-functions/run-enricher.ts
Key points:
  • input is a string, or a list of 1 to 100 messages ({ role: 'user' | 'assistant', content: string }). A user message can also carry file attachments (see below). To hold a conversation, pass a thread rather than replaying earlier turns yourself.
  • By default the call waits for the agent, which can read/update records itself via its own tools — runAgent() resolves once the run completes. Pass waitForCompletion: false to resolve as soon as the run starts: status is then RUNNING and result: null, and the reply lands in the threadId conversation. A run that outlasts 15 minutes also resolves as RUNNING.
  • The agent can also pause until a record changes or some time has passed. runAgent() then resolves with success: true, status: 'SUSPENDED' and result: null; the agent goes on by itself later, and its reply lands in the threadId conversation. A call to a thread whose agent is still waiting fails until it finishes.
  • Every call is stored with its input and status, plus its reply and usage when it completes, so it shows up in the agent’s runs. Without a thread, a call gets a conversation of its own: threadId names it, and a later call never sees it.
  • An app can only run its own agents.
  • The app’s default role must grant the AI permission flag — add SystemPermissionFlag.AI to its permissionFlagUniversalIdentifiers (or set canAccessAllTools: true). Without it, runAgent() fails with a permission error.
  • Set a generous timeoutSeconds on the logic function — agent runs can take several seconds.
  • status is COMPLETED with a non-null result when the run completes without waiting; SUSPENDED with success: true and result: null while it waits; RUNNING with success: true and result: null when the call did not wait for the run; FAILED with success: false and error holding the reason (for example, when the workspace ran out of AI credits mid-run).
src/roles/default-role.ts
Avoid loops: if you call runAgent() from a *.updated database-event trigger and the agent updates the same record, scope the trigger with updatedFields to a field the agent never writes (e.g. the source URL), or guard on whether any target field is still empty before calling runAgent().

Sending files with a message

A user message can carry attachments, so the agent sees a screenshot or a PDF instead of only its name. Each entry references a file your app has already uploaded into the agent-chat folder:
src/logic-functions/describe-screenshot.ts
Uploading requires the UPLOAD_FILE permission flag on the app’s default role, alongside AI. filename is optional and only labels the file for the model.
  • Only user messages may carry attachments. They cannot be represented on an assistant turn, so runAgent() rejects them there rather than dropping them.
  • At most 10 attachments per message.
  • The fileId must name a fully uploaded file in the same workspace, under the agent-chat folder. Anything else fails the call instead of quietly answering without the file.

Asking for a structured result

Pass responseFormat to get a shape for this call only. It takes precedence over the agent’s own responseFormat, so one agent can serve callers that need different outputs. It uses the same schema as defineAgent():
src/logic-functions/score-lead.ts

Continuing a conversation

Pass a thread and Twenty keeps the conversation for you: each call sends only the new input, and the agent sees the earlier turns, including what its tools returned. Use any stable identifier from your side as the key, such as a Slack channel and thread timestamp:
src/logic-functions/reply-in-thread.ts
  • Keys are scoped to your app and agent, so two apps never share a conversation. Only app access tokens can pass a thread.
  • input may only hold user messages: the conversation already holds the agent’s replies.
  • additionalInstructions are given to the model for this call only and are not stored, which suits per-request guidance such as formatting rules or a deadline. They work without a thread too.
  • Every run is stored as a turn with its status, failed ones included. Its usage is stored when the agent returns, even if it ran out of credits. A failed run is not part of the conversation, so the next call does not see it.
  • Calls on the same thread run one at a time: a second call waits for the first to finish, so each one sees the turns before it.
  • When turns run as different members (see below), each one only sees the text of the others’ turns, never their tool results, since those were read with someone else’s permissions.

Running on behalf of a workspace member

Pass runAsWorkspaceMemberId when the run is triggered by a person — a chat bot answering a message, for instance — so the agent acts as that member instead of as the app:
src/logic-functions/answer-question.ts
The run then acts with the member’s own role: it can do whatever that member can do and nothing more, records it creates are attributed to them, and their row-level permissions apply. The agent’s role does not participate — it is the app’s default for runs with nobody behind them. Omit the field for autonomous runs (scheduled jobs, database-event triggers): those keep the agent’s own role. The acting application stays attached to the run’s context for provenance, without narrowing the member’s permissions.Your app is responsible for mapping the person who triggered the run to a workspace member. Naming one requires an app access token, and what a token may name depends on whether it carries a user:
  • A token with no user attached may name any member. A logic function runs with one, and so do tokens minted through client_credentials or from an API key.
  • A token issued on behalf of a user, as a front component receives, may only name that user’s own member.
Anything else — a plain user session, an API key without an app token — may not name a member at all.
runAgent() throws when the workspace member cannot be resolved — an unknown or removed member, or one with no role. It never falls back to the agent’s own role, since that would grant more access than the caller asked for.

Following a conversation

An app reads the conversations it holds with its agents through the same API as the chat: listConversationMessages() returns its history, and streamConversation() yields its live events, token by token. Name the conversation by the agent and the thread key you pass to runAgent(); you can start following it before the first run reaches it.
src/logic-functions/follow-conversation.ts
  • Events are the chat’s own: stream-chunk carries an AI SDK UIMessageChunk and a seq, and notifications such as message-persisted (a message was saved: a member’s, or the reply, which carries its run’s turnId), credits-exhausted (the reply stopped when the workspace ran out of credits), queue-updated or stream-error say when to read the history again. A keepalive comes once connected, then every few seconds: read the history on the first one, since what happened before it is there.
  • To turn chunks into messages as the chat does, pipe them into readUIMessageStream() from the ai package.
  • listConversationMessages() returns { messages, streamCatchup }: the saved messages as AI SDK UIMessages, as the chat builds them, and the chunks of a reply still streaming. Show those chunks, then skip live chunks whose seq is at most streamCatchup.maxSeq, since they repeat them.
  • runAgent() returns runId, the turn the call opens: messages of that run carry it as metadata.turnId. A run that waits goes on in later turns of the conversation.
  • The stream stays open until you abort its signal or break, and throws if the connection drops: stream again to resume.
  • An app reads only its own conversations. Turns a member opened elsewhere, such as in the chat, show only their text, in the history, live and in the agent’s next run: live, their other chunks arrive as empty message-metadata chunks, so seq stays gapless. Turns your app ran as a member (see above) stay whole when it reads on its own.
  • Requires "engines": { "twenty": ">=2.56.0" }.
ai.generate() calls a model from a logic function. Unlike runAgent(), it needs no defineAgent(): the call carries its own instructions, and opens no conversation. Use it for one-shot work such as classifying, summarizing or extracting.
  • input is a string, or a list of { role: 'user' | 'assistant', content } messages.
  • instructions (optional) adds the caller’s system instructions.
  • modelId (optional) picks the model. Without it, the call uses the workspace’s default model.
  • responseFormat (optional) works as in runAgent(). With { type: 'json', schema }, the schema goes on the model request itself, so a JSON answer takes one request (plus tool steps when a scope grants tools).
  • scope (optional) lets the call use the database, as described below.
It resolves to { success, text, object, error, runId, usage }:
  • text is the reply of a text response, and null for a JSON one.
  • object is the result of a JSON response, and null otherwise.
  • error explains a failed run, when success is false. A run that fails resolves this way rather than throwing; a call that cannot start a run, such as one with invalid input, throws.
  • runId identifies the recorded run, with its model, usage and credits.
  • usage is { inputTokens, outputTokens }: what the run spent, including the steps that finished before a failure.
The call acts as whoever made it: the member a user-scoped token belongs to, otherwise your application, otherwise the API key. From a logic function it follows the default clients: it acts as the person who triggered the run, or as your application when nobody did. It never exceeds your application’s role, and an application without a default role cannot call it.Without scope, the call reads and writes no records. To let it use the database, pass a scope, written like a role’s permissions:
A scope only narrows: it grants nothing the caller doesn’t already have.To show the reply while it is written, pass onChunk. The call still resolves to the same result, once the last chunk was handed to onChunk. If the stream’s end never arrives, it stops waiting once the result is in and 5 s pass without a new chunk, and still hands over every chunk received by then:
  • The call follows the run through the same subscription as streamConversation(): it subscribes first, waits until the subscription is live, then starts the run, so it misses no chunk.
  • Chunks are the chat’s own: pipe them into readUIMessageStream() from the ai package to build the message.
  • signal aborts the call: it rejects with the abort error and closes the stream. The run itself goes on, and is recorded as usual.
  • Once the stream is live, a failure of it only stops the chunks: the call still resolves with the run’s result, which is billed either way. The call rejects when the subscription fails before it goes live, so the run never starts, or when onChunk throws.
  • An app follows only its own runs, so onChunk needs an app token.
  • Requires "engines": { "twenty": ">=2.56.0" }.
ai.generate() requires the AI permission flag on the app’s default role, like runAgent().
sendInboxMessage() posts a message from your app in the chats of one or more workspace members. It can end on a tool call the member answers from the chat, after which the conversation continues with Twenty’s assistant. See Inbox for its options, the mention helpers, and how to post from a front component.