Files
upstream-mirror e7da97194a
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
2026-09-17 23:28:13 +05:00

23 KiB

Slack — Self-hosting setup

This guide is for Twenty server admins. It covers creating the Slack app Twenty connects to, the server variables it reads, and the extra wiring the conversational assistant needs.

If you're on Twenty Cloud, these credentials may already be configured — check with your workspace before setting them.

What you need to wire up

Twenty connects to a Slack app that you create and own. Three things must be configured:

  1. A Slack app with the right bot scopes and redirect URL, so workspaces can authorize it.
  2. Its client ID and secret, set as server variables on the Twenty application registration.
  3. For the conversational assistant, a signing secret, event subscriptions and interactivity, so Slack can reach your deployment.

Use a dedicated Slack app — do not reuse one across Twenty apps.

Creating the Slack app

Create it at api.slack.com/apps → Create New App → From a manifest, pasting slack-app-manifest.json with the https://<your-twenty-host> placeholders replaced by your deployment's server origin, and <your-twenty-front-host> under unfurl_domains set to the domain your workspace URLs use (see Record link previews). The manifest is the source of truth for scopes, event subscriptions and the agent surface, so an app created from it only needs its credentials copied out (below).

The manifest enables agent_view, Slack's Agent messaging experience: the app is listed as an agent in Slack's UI, shows clickable suggested prompts at the top of its Messages tab, and replies show a native thinking status instead of placeholder messages, in DMs and channel threads alike. On an app configured by hand instead, enable Agents & AI Apps in the app settings, which also adds the assistant:write scope.

Bot token scopes

Twenty uses Slack's bot OAuth (oauth/v2/authorize with scope=…), so scopes must be added under OAuth & Permissions → Bot Token Scopes and not only under User Token Scopes. Every scope requested at connect time must appear there, since Slack validates the set:

Scope Used for
channels:read conversations.list and the channel picker (public channels)
chat:write post, update, delete, ephemeral
chat:write.public post to public channels without the bot joining
groups:read list private channels the bot is in
im:write open a DM with a member to ask for their consent to a manual link
reactions:write add reactions
app_mentions:read assistant: mentions of the bot
channels:history assistant: thread follow-ups in public channels
groups:history assistant: thread follow-ups in private channels
im:history assistant: direct messages
im:read assistant: confirm with Slack that a conversation really is a direct message
links:read record link previews: receive link_shared events for the registered unfurl domain
links:write record link previews: attach the preview with chat.unfurl
users:read list the Slack roster for the email sweep, and look up a requester's display name
users:read.email match a Slack account to a workspace member by confirmed email
assistant:write agent surface: assistant.threads.* (statuses, titles, suggested prompts)

Adding or removing scopes later means existing installs must re-authorize: disconnect and Add connection again.

Redirect URL

Set it to the origin your Twenty server uses for API routes, not the SPA:

https://<your-twenty-host>/auth/apps/callback

Local monorepo dev is usually http://localhost:3000 — confirm the port twenty-server / SERVER_URL actually uses.

Do not enable Slack's PKCE opt-in. The manifest leaves it off, and it should stay off. Turning it on marks the app a public client, which is one-way and reversible only by Slack support, and Twenty always sends the client secret on the token exchange, so a public-client app is liable to have that exchange rejected. It also makes Slack treat http://localhost… as a desktop redirect, and desktop redirects cannot request bot scopes, which breaks local dev outright. See Slack's Using PKCE docs.

This is separate from Twenty sending a PKCE challenge on the authorize request, which it does regardless of this setting.

Server variables

Set these on the application registration after installing (Settings → Applications → Slack → Application registration, admin only). The client ID and secret are on the Slack app's Basic Information → App Credentials page, alongside the signing secret.

Server variable Required Purpose
SLACK_CLIENT_ID Yes OAuth client ID of your Slack app. Public in OAuth flows.
SLACK_CLIENT_SECRET Yes OAuth client secret of your Slack app. Stored encrypted and never exposed in API responses.
SLACK_WEBHOOK_SECRET Assistant only Slack signing secret, used to verify every Slack Events and interactivity request. Only needed if you enable the conversational assistant.

Connecting a workspace

  1. Install this app (slack) from the app store.
  2. Set the server variables above.
  3. Connections → Add connection, choose Just for me or Workspace shared, and complete the Slack sign-in.

Workflow steps then use that connection's access token: a workspace connection is preferred when present, otherwise the first connection returned for the Slack provider. For posting, either invite the bot to the channel or rely on chat:write.public for public channels. Private channels always require membership.

The conversational assistant

The assistant reuses the same Slack connection — there is no second bot identity. Set SLACK_WEBHOOK_SECRET first: Slack signs the subscription handshake, and without it Slack reports "didn't respond with the value of the challenge parameter."

Event subscriptions

Enable Event Subscriptions on the Slack app and set the Request URL to the slack-events-resolver logic function:

https://<your-twenty-host>/webhooks/server/9ad6fa20-dff5-4d3f-ad5f-084f3c8b0b09

Under Subscribe to bot events, add:

  • app_home_opened — a user opened the bot's Messages tab; sets the suggested prompts
  • app_mention — mentions of the bot in a channel
  • link_shared — a Twenty record link was pasted or posted; renders the record link preview (see Record link previews)
  • entity_details_requested — a user expanded a record link preview; fills the side panel with the record's details
  • message.im — direct messages to the bot
  • message.channels — replies in public-channel threads, for un-mentioned follow-ups
  • message.groups — same, for private channels the bot is in
  • member_joined_channel — optional; lets the bot introduce itself when it is added to a channel
  • app_uninstalled and tokens_revoked — the app was removed from the Slack workspace or its bot token was revoked; releases the Slack team claim so the team can be connected again

Invite the bot to any channel where it should follow threads. Slack may ask you to reinstall after changing subscriptions.

message.channels and message.groups deliver every message posted in the channels the bot belongs to, not only the ones aimed at it, and each delivery costs a logic function invocation. Leave both off to limit the bot to explicit mentions and DMs, at the cost of un-mentioned thread follow-ups.

Event subscriptions added in app upgrades must be added here by hand on existing installs — the Slack app only reads the manifest at creation. Upgrading from any version before 0.4.1 adds app_uninstalled and tokens_revoked; without them the team claim survives a Slack-side removal. No new scopes are involved, so the connection itself needs no re-authorization. Upgrading from any version before 0.7.0 adds link_shared and entity_details_requested plus the links:read / links:write scopes for the record link previews; that one does need a reconnect, and on an app created from an older manifest the Work Object Previews toggle has to be enabled by hand as well (see Record link previews). Upgrading from any version before 0.9.0 adds the slack#/entities/task entity type, which has to be selected under Work Object Previews by hand; until it is, task links stop rendering a card at all. No new scopes or events, so no reconnect.

Interactivity

Enable Interactivity & Shortcuts and set the Request URL to the slack-interactivity-resolver logic function:

https://<your-twenty-host>/webhooks/server/fd756b00-50a2-4816-a919-a1a959a2ed9a

It powers the thumbs up / thumbs down feedback buttons under assistant answers, verified with the same SLACK_WEBHOOK_SECRET. Apps created from the manifest have this preconfigured; on an app configured by hand, the buttons show a warning when clicked until you enable it.

Once both are set, reconnect so the token picks up the assistant scopes.

Permissions

The slack-assistant agent binds to the app's Slack Assistant role automatically on install and upgrade. That role is the ceiling for everything the bot can do.

Where a Slack account is linked to a workspace member, the bot also runs with that member's own permissions, so it can never do more than the person asking. Accounts with no link act with the Slack Assistant role alone, so keep it scoped to what you're comfortable exposing to anyone who can message the bot.

Linking Slack accounts to workspace members

Connecting the Slack workspace links the installer, then sweeps the rest of the roster and links every full member whose confirmed Slack email matches a workspace member's, so most people are linked before anyone messages the bot. Anyone the sweep missed is matched the first time they mention the bot, the same way. Each pair is stored as a Slack User Link record.

The sweep is deliberately narrow. It trusts only a confirmed email on a full member of the installed Slack workspace, never a guest, a Slack Connect user or a bot; it skips any Slack account that already has a link, whatever its state, so it cannot revive a declined consent or overwrite a hand-made mapping; and it treats an email shared by two workspace members as ambiguous and links neither. Slack rate-limits the roster listing, so the sweep reads at most 5 pages of 200 accounts. A larger workspace is read only in part and the result says when it was cut short; a rerun starts from the same place, so the remainder has to be linked by hand.

A link matched on email is re-verified on every request: the bot rechecks that the Slack account's current verified email still points at the same member, and follows the live match rather than the stored record if they disagree. The record is an audit trail, not the source of truth.

Only members of the Slack workspace that installed the app are matched automatically. Guests and Slack Connect users from another Slack workspace are not, because their email is vouched for by someone other than your admin. A member with the roles permission links them by hand from the Slack user links section of the app's Settings tab, or by asking the assistant to link a Slack user to a workspace member; either way the pair is stored with its source set to manual. A guest or Slack Connect user whose email is not in your workspace is linked by their Slack user ID instead; for a Slack Connect user, include their own Slack workspace's team ID so the link matches the messages they send.

Whether a manual link needs the person's approval depends on who it points at:

  • A matching email needs no approval. Linking a Slack account to the workspace member with the same email is just the automatic match set up ahead of that person's first message, so it is stored as an email-matched link and is active immediately, with the same live re-verification.
  • Any other link asks the person first. When you link someone who is in the installed Slack workspace to a member with a different email, the app DMs them explaining that an admin wants the assistant to act with a workspace member's access, with Approve and Decline buttons. The link stays inactive (shown as Awaiting consent) until they approve, and a decline keeps it off. Sending that DM needs the im:write scope, so reconnect after upgrading from a version that did not request it.
  • Slack Connect users from another workspace cannot be DMed this way, so their link is admin-set and active on save, labelled as such. A guest of the installed workspace can be DMed, so they get the normal consent request even though the sweep never matches them automatically.

A consented or admin-set manual link wins over email matching. Slack User Link records can only be written by the app itself, never directly through the API or the UI, so a manual link always reflects both an admin's decision and, where possible, the person's consent.

The Settings section searches members by name so there is no need to paste a member ID, searches the Slack workspace by name or email the same way, and spells out next to the save button whether saving activates the link immediately or sends an approval request. It lists existing links with their status, lets a member with the permission remove a link or resend a pending consent request, and shows a read-only view to anyone without the permission. It renders nothing until Slack is connected.

Links made against a Slack workspace you are no longer connected to stay in that list, because they are the audit trail of who could borrow whose access and when. Disconnecting stops the app receiving that workspace's messages, so these links normally go quiet, but it does not revoke them. An email-matched link is inert either way, because email matching trusts only members of the installed workspace. A consented manual link is not: it is keyed on the sender's own Slack team, so it is still honoured if that person can reach the bot another way, such as through Slack Connect in the workspace you are connected to now. That is the same mechanism that makes manual links work for Slack Connect users in the first place.

So the list marks these rows rather than hiding them, showing Slack workspace disconnected instead of a live status and offering no consent resend for them. Removing the row is what revokes the link, and disconnecting a Slack workspace never does so on your behalf. If Slack does not confirm the installed team when the section loads, every row keeps its stored status rather than being marked wrongly.

The same section lists Slack users with no link yet, so you can see who the sweep skipped and finish the job in place: each row has its own member picker and says whether linking activates immediately or asks the person first. An Auto-link by email button reruns the sweep on demand, which is how you cover a workspace that connected before the sweep existed (connect hooks do not re-fire) and people who joined Slack after connection.

Pasting a link to a Twenty person, company, opportunity, note or task renders it as a work object preview: a card carrying the record's name and a few key fields. The card appears as soon as the link is pasted into the composer, before the message is sent. Optional — skip this section and links stay plain text.

Register the domain of your Twenty front URL under Event Subscriptions → App unfurl domains: the workspace URL members open, without the scheme, for example crm.example.com. Apps created from the manifest have <your-twenty-front-host> preconfigured; replace it before pasting. Slack only sends link_shared events for registered domains.

A workspace on a custom domain still serves its subdomain host, and links get copied from either, so register both: crm.example.com and acme.twenty.com. Previews resolve a link from either host and point the card back at the custom one; a host you leave unregistered simply never unfurls.

The previews also need the links:read and links:write scopes and the link_shared and entity_details_requested subscriptions from the lists above. On an existing install, add them and reconnect (disconnect, then Add connection again) so the token picks up the scopes.

The manifest turns the previews themselves on, through features.rich_previews with the two entity types they use: slack#/entities/item for people, companies, opportunities and notes, and slack#/entities/task for tasks, which Slack renders with native status, due date and assignee fields. On an app created by hand, or from a manifest older than 0.7.0, open Work Object Previews in the app settings, enable the toggle and select both the Item and Task entity types. An app created from a 0.7.x or 0.8.x manifest already has the toggle on with Item selected, so it only needs Task added. A type you leave unselected makes Slack ignore the unfurl metadata for those records, so no card renders and nothing in the logs says why.

Slack stamps your Slack app's icon on the corner of every record card for attribution, and an app without one gets Slack's generic placeholder. Upload the Twenty logo (or your own) under Basic Information → Display Information → App icon; icons cannot be set from a manifest.

Previews are rendered only when the person who posted the link maps to a workspace member, by the same matching described above. The card is visible to everyone in the channel, so it stays to a handful of headline fields. Expanding it opens a side panel with the full field set; anyone who can see the card can open it, external users in Slack Connect channels included, so the panel is likewise filled only for viewers who map to a workspace member, and everyone else gets a short notice instead.

Record links the bot itself posts (assistant answers, workflow message steps) carry the preview too. Slack never sends link_shared for an app's own messages, so those previews are attached as the message is posted; the member gate does not apply there, since what the bot says is already decided upstream — the assistant runs with the requester's permissions, and workflow steps post what their author configured.

Behaviour notes

  • Suggested prompts. With app_home_opened subscribed and the Agents feature enabled, opening the bot's Messages tab shows clickable example prompts, refreshed on every open.
  • Direct messages. Each new message in the bot's Messages tab starts its own conversation thread: the bot shows a "Twenty is thinking…" status while it works, replies in the thread, and titles the thread after the question. Context is scoped to the thread, not the whole DM history.
  • Channel mentions. The bot shows its thinking status in the mention's thread and posts its answer as a thread reply. The status is only visible with the thread open, so the channel stays quiet until the answer arrives.
  • Answer feedback. Short answers end with Slack's native thumbs up / thumbs down buttons. A click stores a Positive or Negative rating on the matching Slack Assistant Request record (last click wins). Very long answers fall back to a plain message without buttons.
  • Thread memory. After a successful reply the bot stays active in that thread, so follow-ups need no mention. Channel threads stay active for 24 hours after the last reply (each reply renews it); DM threads never expire.
  • No silent dead-ends. A mention or DM with no request text gets a short hint reply. The first follow-up in a thread whose 24-hour window has lapsed gets an ephemeral nudge (only that member sees it) to mention the bot again.
  • Channel welcome. With member_joined_channel subscribed, the bot posts a short introduction the first time it is added to a channel, with the details in a thread reply so the channel itself stays quiet. It names both ways to reach the bot: mention it in the channel, or DM it to keep a question and its answer out of the channel. It fires once per channel for 30 days, and only for the bot's own join — humans joining afterwards trigger nothing. Skip the subscription if you would rather it arrived silently.
  • One Slack workspace per Twenty workspace. Connecting Slack claims that Slack team for the connecting Twenty workspace, and on the same server a second Twenty workspace connecting the same team is rejected. Removing the last connection using it releases the claim, and so does uninstalling the app or removing it on the Slack side — with app_uninstalled and tokens_revoked subscribed, Slack reports the removal and the claim is released. The dead connection still shows under Connections until you remove it; reconnecting means removing it and adding a new one.

Workflow field names

Fields use camelCase in the step UI:

  • slackChannelId — channel or DM, name or ID
  • messageText, newMessageText — body to post / the replacement on update
  • messageTimestamp — Slack's per-message id, same value as the tool output slackTs when chaining steps
  • parentMessageTimestamp — thread replies only
  • messageFormat — markdown sends the body as Slack markdown_text (**bold**), plain sends text with markup disabled, omitted uses Slack's default for text
  • recipientSlackUserId — ephemeral steps
  • emojiName — Slack shortcode, for example white_check_mark

Troubleshooting

Symptom Likely cause Fix
Slack refuses the install with "doesn't have a bot user to install" Scopes were added under User Token Scopes only Add every scope from the table above under Bot Token Scopes, then reconnect
OAuth fails from a localhost redirect Slack's PKCE opt-in is on, so Slack treats the redirect as a desktop one and refuses bot scopes Leave the PKCE opt-in off (it cannot be turned off again once set), or use an https:// redirect and point SERVER_URL at the same base URL
Slack reports "didn't respond with the value of the challenge parameter" SLACK_WEBHOOK_SECRET is not set, so the handshake fails signature verification Set it on the application registration, then re-save the Request URL
The bot answers mentions but ignores thread follow-ups channels:history / groups:history missing, the bot isn't in the channel, or the 24-hour window lapsed Confirm the subscriptions and scopes, invite the bot, and mention it again to reopen the thread
Feedback buttons show a warning when clicked Interactivity & Shortcuts is not enabled, or its Request URL is wrong Enable it and set the slack-interactivity-resolver URL above
Connecting is rejected because the Slack team is already claimed Another Twenty workspace on this server has connected the same Slack team Remove the connection on that workspace, or uninstall the app there, to release the claim
A manual link stays Awaiting consent The person has not answered the consent DM, or im:write was not granted so it was never sent Resend the request from the Slack user links settings section; if nothing arrives, reconnect to grant im:write
The bot acts with the Slack Assistant role instead of the member's permissions The Slack account has no link, or its verified email no longer matches the member Check the link's status in the settings section and link the account by hand if the email cannot match