> ## Documentation Index
> Fetch the complete documentation index at: https://docs.twenty.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Skills & Agents

> Define AI skills and agents for your app.

<Warning>
  Skills and agents are currently in alpha. The feature works but is still evolving.
</Warning>

Apps can define AI capabilities that live inside the workspace — reusable skill instructions and agents with custom system prompts.

<AccordionGroup>
  <Accordion title="defineSkill" description="Define AI agent skills">
    Skills define reusable instructions and capabilities that AI agents can use within your workspace. Use `defineSkill()` to define skills with built-in validation:

    ```ts src/skills/example-skill.ts theme={null}
    import { defineSkill } from 'twenty-sdk/define';

    export default defineSkill({
      universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
      name: 'sales-outreach',
      label: 'Sales Outreach',
      description: 'Guides the AI agent through a structured sales outreach process',
      icon: 'IconBrain',
      content: `You are a sales outreach assistant. When reaching out to a prospect:
    1. Research the company and recent news
    2. Identify the prospect's role and likely pain points
    3. Draft a personalized message referencing specific details
    4. Keep the tone professional but conversational`,
    });
    ```

    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.
  </Accordion>

  <Accordion title="defineAgent" description="Define AI agents with custom prompts">
    Agents are AI assistants that live inside your workspace. Use `defineAgent()` to create agents with a custom system prompt:

    ```ts src/agents/example-agent.ts theme={null}
    import { defineAgent } from 'twenty-sdk/define';

    export default defineAgent({
      universalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
      name: 'sales-assistant',
      label: 'Sales Assistant',
      description: 'Helps the sales team draft outreach emails and research prospects',
      icon: 'IconRobot',
      prompt: 'You are a helpful sales assistant. Help users with their questions and tasks.',
    });
    ```

    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](/developers/extend/apps/config/roles#agent-roles).

    By default an agent returns free-form text. To get structured output, set `responseFormat` to `{ type: 'json' }` and provide a `schema`:

    ```ts src/agents/structured-agent.ts theme={null}
    import { defineAgent } from 'twenty-sdk/define';

    export default defineAgent({
      universalIdentifier: 'c4d5e6f7-a8b9-0123-cdef-456789012345',
      name: 'lead-scorer',
      label: 'Lead Scorer',
      prompt: 'Score the lead and explain your reasoning.',
      responseFormat: {
        type: 'json',
        schema: {
          type: 'object',
          properties: {
            score: { type: 'number', description: 'Lead score from 0 to 100' },
            summary: { type: 'string', description: 'Short reasoning for the score' },
          },
          required: ['score', 'summary'],
          additionalProperties: false,
        },
      },
    });
    ```

    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`.
  </Accordion>

  <Accordion title="runAgent" description="Run an agent from a logic function">
    `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`:

    ```ts src/logic-functions/run-enricher.ts theme={null}
    import { runAgent } from 'twenty-sdk/logic-function';

    const { threadId, status, result, error, success } = await runAgent({
      agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
      input: 'Enrich House Ad <recordId>: fill empty fields from its listing URL.',
    });
    ```

    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](/developers/extend/apps/config/roles) 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).

    ```ts src/roles/default-role.ts theme={null}
    import { defineApplicationRole, SystemPermissionFlag } from 'twenty-sdk/define';

    export default defineApplicationRole({
      universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
      label: 'Default function role',
      // runAgent() requires the AI permission flag on the app's default role.
      permissionFlagUniversalIdentifiers: [SystemPermissionFlag.AI],
    });
    ```

    <Warning>
      **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()`.
    </Warning>

    ### 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:

    ```ts src/logic-functions/describe-screenshot.ts theme={null}
    const { result } = await runAgent({
      agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
      input: [
        {
          role: 'user',
          content: 'What does this error screen say?',
          attachments: [{ fileId: uploadedFileId, filename: 'error.png' }],
        },
      ],
    });
    ```

    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()`:

    ```ts src/logic-functions/score-lead.ts theme={null}
    const { result } = await runAgent({
      agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
      input: `Score this lead: ${JSON.stringify(lead)}`,
      responseFormat: {
        type: 'json',
        schema: {
          type: 'object',
          properties: {
            score: { type: 'number', description: 'Lead score from 0 to 100' },
          },
          required: ['score'],
        },
      },
    });
    ```

    ### 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:

    ```ts src/logic-functions/reply-in-thread.ts theme={null}
    const { result, threadId } = await runAgent({
      agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
      input: 'What was the last touchpoint?',
      additionalInstructions: 'Answer in Slack markdown.',
      thread: { key: `${channelId}:${threadTs}`, title: 'Acme renewal' },
    });
    ```

    * 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:

    ```ts src/logic-functions/answer-question.ts theme={null}
    const { result } = await runAgent({
      agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
      input: 'How many open opportunities do we have?',
      runAsWorkspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
    });
    ```

    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.

    <Warning>
      `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.
    </Warning>
  </Accordion>

  <Accordion title="sendInboxMessage" description="Start or continue a conversation in members' chats">
    `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.

    ```ts src/logic-functions/notify-first-recording.ts theme={null}
    import { sendInboxMessage } from 'twenty-sdk/logic-function';

    const { threadId } = await sendInboxMessage({
      workspaceMemberIds: [workspaceMemberId],
      threadKey: 'first-call-recording',
      idempotencyKey: 'first-call-recording',
      title: 'Your first call recording is ready',
      text: 'Your first call was recorded: **Weekly sync**.',
      toolCall: {
        toolName: 'ask_question',
        input: {
          header: 'Share',
          question: 'Do you want to share it with the other attendees?',
          options: [{ label: 'Draft a recap email' }, { label: 'Not now' }],
        },
      },
    });
    ```

    Key points:

    * `workspaceMemberIds` lists the members who receive the conversation, at least
      one. The first member of a new conversation owns it, and the others follow it
      as participants. A later send under the same key that lists new members adds
      them; members it leaves out keep the conversation.
    * `threadKey` picks the conversation for this app, shared by every member it is
      sent to. A new key starts a conversation titled `title`; a key you used before
      adds the message to that conversation.
    * `idempotencyKey` identifies the message within the conversation. Sending
      again with the same keys writes nothing new, so retries and concurrent runs
      never duplicate a message.
    * `toolCall` (optional) ends the message on one of:
      * a call the member answers, which pauses the conversation: `ask_question`
        (one multiple-choice question), `request_form` (fields to fill in) or
        `propose_tool_call` (a call to approve). Its `input` takes the `toolName`,
        the `arguments` you would call the tool with and a one-sentence `summary`.
        The tool must be one your app's default role could run itself, such as
        `create_one_person` or `update_one_opportunity` (with the record `id`); an
        email (`send_email` or `draft_email`, with `recipients`, `subject` and a
        `body` in HTML) needs no permission of your app. The member reviews the call
        in the same card as a call the assistant proposes, edits it if they want, and
        the approved call runs with their own access;
      * one of your app's own tools, by `logicFunctionUniversalIdentifier`, rendered
        by its front component with the `input` and `output` you pass. It does not
        pause the conversation.
    * Only one call the member answers can wait in a conversation at a time:
      sending another before they answer fails with `THREAD_AWAITING_ANSWER`. Plain
      messages and app tool calls are still accepted.
    * Only the member who owns a conversation can delete it, and a deleted
      conversation is gone for every member: it is not recreated, and nothing is
      added to it. The other members can unsubscribe to take it out of their own
      inbox.
    * Every member must have the `AI` permission, and the call fails without
      writing the message when one of them cannot have the conversation. Your app's
      [default role](/developers/extend/apps/config/roles) must grant
      `SystemPermissionFlag.AI`.
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.