Search by

jordandalton / tackle-slack

Drive a Laravel Tackle coding session from Slack — over Socket Mode, so it works from anywhere with no public URL.

Maintainers

Package info

github.com/JordanDalton/tackle-slack

pkg:composer/jordandalton/tackle-slack

Transparency log

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-09-03 00:20 UTC

This package is auto-updated.

Last update: 2026-09-03 00:58:38 UTC


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 clear resets the conversation; /tackle help lists 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/appsCreate New AppFrom 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:

  1. Install to Workspace, and copy the Bot User OAuth Token (xoxb-…) from OAuth & Permissions into TACKLE_SLACK_BOT_TOKEN.
  2. Under Basic Information → App-Level Tokens, generate one with the connections:write scope and put it (xapp-…) in TACKLE_SLACK_APP_TOKEN.
  3. 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