jordandalton / tackle-slack
Drive a Laravel Tackle coding session from Slack — over Socket Mode, so it works from anywhere with no public URL.
Requires
- php: ^8.3
- ext-curl: *
- ext-json: *
- ext-mbstring: *
- ext-openssl: *
- jordandalton/laravel-tackle: ^1.50
- jordandalton/laravel-tackle-remote: ^0.1.3
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Drive a Laravel Tackle coding session from Slack.
php artisan tackle:slack
Then message the app. Send a task, watch it work, press Yes when it asks before running something destructive.
Why Slack, and not a hosted bot
Outbound only, still. This uses Socket Mode: a WebSocket your machine opens to Slack. No public URL, no tunnel, no hosted process, no port forwarding. A laptop behind NAT can be driven from a phone on the train, because Slack's servers are the relay.
The two other ways to hear from Slack both fail that test. The Events API needs
a URL Slack can POST to. Polling conversations.history was rate-limited in
2025 to one call a minute for apps not on the Marketplace, which is not a
transport, it is a stall.
And a team already lives there. A session in a channel is one the whole team can watch, and one where the approval a destructive step needs can come from whoever is around — as long as they are on the list.
What it took to build
Almost nothing, and that is the point.
Tackle Remote already separates the agent from the way a human reaches it:
| Piece | Job | Knows about browsers? |
|---|---|---|
SessionLoop |
Pops the inbox, drives the agent, appends events | No |
RemoteState |
inbox / events / question / answers, as files | No |
RemoteInteraction |
InteractionPolicy over that protocol |
No |
server/router.php |
HTTP transport | Yes — and only this |
So this package adds no new InteractionPolicy and no agent code. It is a
pump between that state directory and a channel: drain events out, offer the
pending question as buttons, push replies back into the inbox. Every
ConfirmAction, every destructive RunArtisan, every MutateDatabase commit
routes to Slack automatically, because they already routed through
InteractionPolicy.
The only new machinery is a WebSocket client, because a library would have been the package's one dependency for a protocol that fits on two screens.
Security: read this part
Anyone who can message this app can run code on the machine hosting it — and in a channel, that is everyone in the channel.
So allowed_users is not a convenience — it is the entire security model. An
unlisted user is not answered, not rate-limited, not asked to authenticate.
Their message is dropped before it can reach the agent. An empty allowlist
means nobody, and the command refuses to start rather than quietly accepting
the workspace.
TACKLE_SLACK_BOT_TOKEN=xoxb-... # OAuth & Permissions → Bot User OAuth Token TACKLE_SLACK_APP_TOKEN=xapp-... # Basic Information → App-Level Tokens (connections:write) TACKLE_SLACK_CHANNEL=C0123456789 # where the session talks; a D… id for a DM TACKLE_SLACK_USERS=U0123456789 # comma-separated user ids
Your code goes to Slack, and to the channel. The agent will echo file contents, stack traces, and whatever else it reads. In a direct message that is between you and Slack. In a channel it is between you, Slack, and everyone who can read the channel — which is the point of a channel, and also something to decide on purpose. For a client's project, or anything with a compliance boundary, it may simply be a no.
What it does
- Streams into one message. Assistant text arrives as deltas; posting each one would be unusable, so a turn accumulates into a single message that is edited in place. A new one starts when the message gets long, rather than truncating the answer.
- Tool calls, statuses and errors appear as they happen, in that message.
- Approvals as buttons, offered once. A press answers only the question that is currently open — by the time you look the agent may have moved on, and answering a question it is no longer asking is worse than missing one. The buttons are then replaced with the decision and who made it, so the channel shows a record rather than a live control.
- Notifies the right person. Slack only notifies about a channel message when it names you, so a question names whoever last spoke to the agent. Nothing else does — progress is for glancing at.
/tackle clearresets the conversation;/tackle helplists the session's commands.- Survives Slack going away. A transport that dies would take the agent with it, so a failed pump is reported to the terminal and the loop carries on. A dropped socket reconnects on the next pump.
Install
composer require jordandalton/tackle-slack
1. Create the app
Go to api.slack.com/apps → Create New App → From a manifest, pick your workspace, and paste this:
display_information: name: Tackle description: Drive a Laravel Tackle coding session from Slack features: bot_user: display_name: Tackle always_online: false slash_commands: - command: /tackle description: Talk to the Tackle session usage_hint: "help | clear | <task>" should_escape: false oauth_config: scopes: bot: - chat:write - commands - channels:history - groups:history - im:history - mpim:history - im:write - users:read settings: event_subscriptions: bot_events: - message.channels - message.groups - message.im - message.mpim interactivity: is_enabled: true socket_mode_enabled: true org_deploy_enabled: false token_rotation_enabled: false
Then:
- Install to Workspace, and copy the Bot User OAuth Token (
xoxb-…) from OAuth & Permissions intoTACKLE_SLACK_BOT_TOKEN. - Under Basic Information → App-Level Tokens, generate one with the
connections:writescope and put it (xapp-…) inTACKLE_SLACK_APP_TOKEN. - Invite the app to the channel you want to use (
/invite @Tackle), or open a direct message with it.
2. Pair
Find out who is allowed to drive it. Message the app, then:
php artisan tackle:slack --pair
U0123456789 Jordan Dalton (channel C0123456789)
TACKLE_SLACK_USERS=U0123456789
TACKLE_SLACK_CHANNEL=C0123456789
Allow Jordan Dalton to drive this project from this channel? (yes/no) [no]
Say yes and it writes both lines into .env — appending to the user list
rather than replacing it, so pairing a second person does not revoke the first,
and setting the channel only if nothing has chosen one yet.
--pair only listens and reports. It acts on nothing, so anyone in the
workspace can make it print their id — and none of them can make it do
anything. The decision stays with you.
Stop any running session first. Slack spreads events across every open
Socket Mode connection, so a session running alongside --pair will take turns
swallowing your messages with no error anywhere to explain where they went.
3. Start a session
php artisan tackle:slack php artisan tackle:slack --session=billing # a separate conversation php artisan tackle:slack --channel=D0123456789 # talk somewhere else this time
Leave TACKLE_SLACK_CHANNEL unset and the session opens a direct message with
the first allowed user.
In your dev script
composer dev runs its processes under concurrently --kill-others, which
tears the whole environment down the moment any one of them exits. Use
--if-configured so a machine without a Slack setup idles instead of taking
the server, queue and Vite with it:
"dev": [ "Composer\\Config::disableProcessTimeout", "npx concurrently -c \"#93c5fd,#c4b5fd,#fdba74,#22d3ee\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"npm run dev\" \"php artisan tackle:slack --if-configured\" --names=server,queue,vite,slack --kill-others" ]
Run on its own without a token it still fails loudly, which is what you want when you meant to start it.
Commands
Slack intercepts anything you type beginning with /, so /clear on its own
never reaches anyone. The manifest registers /tackle for that:
/tackle or @Tackle |
What this is and how to use it |
/tackle help |
Built-in and project commands |
/tackle clear |
Forget the conversation and start fresh |
/tackle deploy-check |
Any command the session has published |
/tackle fix the tests |
Anything else after /tackle is a task |
| anything else | A task for the agent |
Restarting the command resumes rather than resets — that is what the
"Resumed session" line means. /tackle clear, or a new --session, is what
starting over actually looks like.
Files and images are not supported yet; the bot says so rather than ignoring them.
Troubleshooting
Nothing arrives. TACKLE_SLACK_DEBUG=1 traces what the pump is doing —
which events it read, whether it posted or edited, and when the socket
reconnects.
not_in_channel. The app has to be invited before it can post there:
/invite @Tackle.
channel_not_found. TACKLE_SLACK_CHANNEL wants the id (C…, G…,
D…), not the name. It is at the bottom of the channel's details pane.
The app ignores you in a channel. It only listens in the one channel it was
started for, and only to the users on the list. --pair will tell you what
Slack thinks both ids are.
/tackle says "failed with the error dispatch_failed". The session is not
running, so nothing acknowledged the command. Start one.
Status
72 tests, against an in-memory Slack — the security model is not something to verify by hand against a live workspace. The WebSocket client has been exercised against a live echo server, including the 64-bit length form and a fragmented read.
Flown against a real workspace on a real project: a task sent from a channel
edited a Vue component, narrated what it was doing as it went, and was reverted
the same way. The first live run found --pair allowlisting the bot with itself
— its own "has joined the channel" event is a message from its own user id — and
that is fixed.
Related
- laravel-tackle — the harness
- laravel-tackle-remote — the state protocol and session loop this reuses
- tackle-telegram — the same idea over Telegram, for one person and a phone
- tackle-mobile — the native client, which has the reachability problem this one does not