> ## Documentation Index
> Fetch the complete documentation index at: https://docs.upshiftcloud.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.

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

> **defineSkill**
>
> 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.

> **defineAgent**
>
> 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](https://docs.upshiftcloud.com/en/developers/extend/apps/config/roles.md#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`.

> **runAgent**
>
> 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](https://docs.upshiftcloud.com/en/developers/extend/apps/config/roles.md) 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()`.
>
> ### 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 Upshift 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.

> **sendInboxMessage**
>
> 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 Upshift'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](https://docs.upshiftcloud.com/en/developers/extend/apps/config/roles.md) must grant
>   `SystemPermissionFlag.AI`.


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