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

403 lines
23 KiB
Markdown

# 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](https://api.slack.com/apps) → **Create New App
→ From a manifest**, pasting [`slack-app-manifest.json`](./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:
```text
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](https://docs.slack.dev/authentication/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:
```text
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:
```text
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.
## Record link previews (work objects)
Pasting a link to a Twenty person, company, opportunity, note or task renders it
as a [work object](https://docs.slack.dev/messaging/work-objects) 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 |