Skip to content

Add slack-channel-listener skill and slack-reply plugin (draft)#245

Draft
tofarr wants to merge 5 commits into
OpenHands:mainfrom
tofarr:slack-channel-listener
Draft

Add slack-channel-listener skill and slack-reply plugin (draft)#245
tofarr wants to merge 5 commits into
OpenHands:mainfrom
tofarr:slack-channel-listener

Conversation

@tofarr
Copy link
Copy Markdown
Contributor

@tofarr tofarr commented May 19, 2026

Summary

Adds a guided skill (slack-channel-listener) and a paired plugin (slack-reply) that together let a user stand up an OpenHands automation which:

  1. Watches one or more Slack channels for a configurable trigger phrase.
  2. Starts a new agent conversation per matching message, using the message (plus optional thread / channel context) as the initial prompt.
  3. Posts the agent's final answer back to Slack as a threaded reply to the triggering message when the agent goes idle.

Motivation: a user asked "create an automation that monitors #tim-test-openhands-5 and starts a conversation whenever someone posts @thems-fightin-words". That request generalises naturally into a reusable skill; this PR packages that generalisation.

What's in the box

  • skills/slack-channel-listener/ - guided flow with SKILL.md plus four reference docs covering push setup, poll setup, multi-channel scope, context options, and tarball assembly.
  • plugins/slack-reply/ - reusable Python scripts (agent_event.py, agent_poll.py, slack_client.py, prompt.py, config.py, setup.sh) that the skill packages into a custom-automation tarball.
  • Marketplace entries + the usual scripts/sync_extensions.py regeneration.

Architecture decisions worth flagging for review

Two trigger paths, picked at install time

Path When Why
Push (Slack Events API → custom webhook → event automation) OpenHands Cloud, or self-hosted with public HTTPS ingress Lowest latency; no wasted runs.
Poll (cron automation → conversations.history / search.messages) Laptops, corporate firewalls, anywhere without inbound HTTPS Outbound HTTPS only; works behind every firewall I've encountered.

The push path is the obvious right answer for hosted deployments; the poll path exists so the skill is usable in dev environments and behind corporate firewalls, which would otherwise be a blocker.

Poll uses Slack reactions as persistent state

Rather than persisting last_seen_ts somewhere, the poll script claims a message by adding a 👀 reaction before working on it and finishes with (or ⚠️ on failure). This is idempotent, concurrent-safe, visible in the Slack UI, and needs no external store - which matters because the automation sandbox isn't a stable persistence layer.

Multi-channel scope is a first-class config

SLACK_CHANNEL_SCOPE is one of single / list / all-public / all-accessible. The most interesting mode is all-accessible, which uses a user token with search:read and search.messages to surface matches across every channel the user can see in one API call. Push mode can't do all-accessible (apps can't self-join private channels en masse), so the skill steers users toward poll for that scope.

Trigger phrases are a configurable list

Not a single string - SLACK_TRIGGER_PHRASES is a comma-separated list, case-insensitive substring match. Common case is one phrase; multiple is supported for teams that want @bot, /triage, and hey openhands to all work.

"Post on idle" without an in-Conversation hook

The Conversation.run() call already blocks until the agent terminates (finished, awaiting input, or errored). The script reads the last assistant message from conversation.state.events after run() returns and posts it. No callback gymnastics needed - the post-run boundary in the script is the idle boundary. The script also wraps the run in try/except so errors produce a threaded ⚠️ reply rather than silent failure.

How I'd recommend reviewing

  1. Read skills/slack-channel-listener/SKILL.md - that's the entry point a future agent would see.
  2. Skim plugins/slack-reply/scripts/agent_event.py and agent_poll.py - those are the meat.
  3. Check the four reference docs for accuracy on Slack API contracts and the OpenHands Automations API.

Status / known follow-ups

This is draft intentionally - I want to dogfood it locally (creating the original @thems-fightin-words automation against #tim-test-openhands-5) and feed back any rough edges before requesting review. Known TODOs:

  • End-to-end test in poll mode against a real Slack workspace (about to do this).
  • End-to-end test in push mode (needs a publicly reachable backend; I'll do this against staging).
  • Decide whether to expose interim "working on it" status updates (currently just a 👀 reaction; could also post a placeholder message and edit it).
  • SLACK_INCLUDE_FILES / SLACK_INCLUDE_REACTIONS flags (sketched in references/context-options.md).
  • Concurrency cap in poll mode (today it processes matches sequentially within a single run, bounded by timeout).

No new dependencies in the repo itself; the plugin's setup.sh adds slack_sdk only inside automation sandboxes.

This PR was opened by an AI agent (OpenHands) on behalf of @tofarr.

Introduce a guided skill that creates an OpenHands automation to monitor
one or more Slack channels for configurable trigger phrases and start
new agent conversations per match, with results posted as threaded
replies.

Supports two architectures:
- Push mode via Slack Events API (lowest latency, requires backend
  reachable from Slack).
- Poll mode via cron + Slack Web API (works behind firewalls / on
  laptops; uses reactions as persistent state).

Scope can be a single channel, a list, all public channels the bot has
joined, or every channel the user can see (via search.messages). Reply
includes optional thread context, recent channel context, and user-ID
resolution; all behind environment-variable flags.

The slack-reply plugin contains the reusable Python scripts that the
skill packages into a custom-automation tarball.

Co-authored-by: openhands <openhands@all-hands.dev>
Replace the reaction-as-state design with a small SQLite database under
$SLACK_STATE_DIR (default /automation/storage/state). claim() does an
atomic INSERT OR IGNORE, mark_done()/mark_failed() update the row.
Reactions stay available as opt-in visual UX via reply_mode=thread+reaction
but no longer drive control flow, so users without reactions:read/write
scopes can use plain thread mode.

Also bump the recommended cron cadence from */2 * * * * to * * * * *.
The state store makes high-frequency polling safe (each message gets
exactly one run regardless of how often the cron fires), and the script
exits in well under a second when there's nothing to do, so the cost is
just a few extra sandbox dispatches per day.

Co-authored-by: openhands <openhands@all-hands.dev>
…SQLite

Refactor scripts/state.py around a Store protocol with two
implementations:

- KVApiStore: persists state in the per-automation KV store from
  OpenHands/automation#69. Storage layout is a single JSON document
  keyed 'slack_listener_state' holding {<channel>:<ts>: {status,
  claimed_at, finished_at, error}}. Mutations are read-modify-write
  with ?if_version=N, with jittered exponential backoff on 409
  (lock timeout or version mismatch).
- SQLiteStore: existing on-disk implementation, unchanged in behavior,
  kept as a fallback for environments without the KV store. Defaults
  to /automation/storage/state but the path is overridable via
  SLACK_STATE_DIR.

A get_store() factory picks KVApiStore when both AUTOMATION_KV_TOKEN
and an API URL are present, otherwise falls back to SQLiteStore. Both
backends now expose prune_older_than(cutoff_iso) so agent_poll.py can
keep state within the KV API's 64 KB cap and stop the SQLite file from
growing without bound; the cron entrypoint prunes at 2x lookback on
every run.

The skill now sets enable_kv_store: true on the automation create
calls and the docs explain the two-backend selection logic. Reactions
are now strictly opt-in visual UX (reply_mode=thread+reaction); plain
thread mode no longer requires reactions:read/write scopes.

Tested both backends end-to-end (lifecycle + factory + KV 409 retry)
with an in-process stub of the KV API.

Co-authored-by: openhands <openhands@all-hands.dev>
setup.sh was using 'uv pip install --system', which writes to the
interpreter's site-packages (/usr/local/lib/pythonX.Y/...). That
directory is root-owned in the automation sandbox; the run process
runs as an unprivileged user, so the install died on EACCES halfway
through (annotated_doc was the package that surfaced it during dogfood
on PR OpenHands#245). Worse, the dispatcher didn't propagate the setup failure
into run.error_detail, so runs stayed in RUNNING forever until the
10-minute timeout.

Fix is a dedicated venv at $HOME/.venvs/slack-listener, plus a
companion run.sh entrypoint wrapper that exec's the venv's python on
the requested script (agent_event.py or agent_poll.py). The venv path
is overridable via $SLACK_LISTENER_VENV for testing.

Smoke-tested locally as an unprivileged user (matching the failing
sandbox's constraint): venv creation, package install, and import of
openhands.sdk / openhands.workspace / openhands.tools / slack_sdk all
succeed end-to-end. SDK v1.22.1 confirmed working in the venv.

Documentation updated:
- plugins/slack-reply/SKILL.md describes setup.sh + run.sh roles.
- skills/slack-channel-listener/SKILL.md, references/{tarball-build,
  poll-setup,push-setup}.md now stage run.sh into the tarball and
  set entrypoint to 'bash run.sh agent_{event,poll}.py'.

Co-authored-by: openhands <openhands@all-hands.dev>
The automation sandbox is reused across runs (cron ticks share state).
'uv venv' aborts with exit code 2 if the target directory already
exists, which made the first cron tick succeed but every subsequent
one fail with:

  error: Failed to create virtual environment
    Caused by: A virtual environment already exists at
    `/home/openhands/.venvs/slack-listener`. Use `--clear` to replace it

Guard the venv creation with '[ ! -x $VENV/bin/python ]' so it's a
no-op on re-runs. 'uv pip install' itself is already idempotent and
will still surface dependency upgrades when versions change.

Smoke-tested two back-to-back runs locally — first creates the venv
and installs, second is a no-op exiting 0.

Co-authored-by: openhands <openhands@all-hands.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants