Sending PyLabRobot notifications to Slack#
A basic example of sending Slack messages from Python, wired into a PLR protocol. Nothing here is PLR-specific — for the full webhook API (formatting, attachments, threading, Block Kit), see Slack’s Incoming Webhooks docs.
Prerequisites#
A Slack Incoming Webhook URL
1. Store the webhook URL#
Save the URL to ~/.plr_slack_webhook — outside the repo, mode 600 so only your user can read it:
umask 077 && echo "https://hooks.slack.com/services/..." > ~/.plr_slack_webhook
The helper below reads from this file. Pass webhook_url= to override (e.g. for testing).
2. A tiny stdlib notifier#
Slack’s incoming-webhook endpoint accepts a JSON POST. The standard library is enough — no httpx, requests, or slack_sdk needed.
import json
import urllib.request
import urllib.error
from pathlib import Path
WEBHOOK_FILE = Path("~/.plr_slack_webhook").expanduser()
def _load_webhook() -> str | None:
if WEBHOOK_FILE.exists():
return WEBHOOK_FILE.read_text().strip() or None
return None
def slack_notify(text: str, webhook_url: str | None = None, timeout: float = 5.0) -> None:
url = webhook_url or _load_webhook()
if not url:
print(f"[slack_notify: no webhook configured at {WEBHOOK_FILE}] {text}")
return
req = urllib.request.Request(
url,
data=json.dumps({"text": text}).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
resp.read()
except (urllib.error.URLError, TimeoutError) as e:
print(f"[slack_notify failed: {e}] {text}")
slack_notify(":wave: Hello from PyLabRobot")
3. Wrap a PLR run#
Send a message at start, on success, and on failure. The finally block guarantees lh.stop() runs even if the protocol crashes.
import traceback
from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends.hamilton.STAR_chatterbox import STARChatterboxBackend
from pylabrobot.resources.hamilton import STARLetDeck
# Using the chatterbox backend so this notebook runs without hardware.
# On a real machine, swap in `STARBackend()` (or whatever your machine uses).
lh = LiquidHandler(backend=STARChatterboxBackend(), deck=STARLetDeck())
try:
await lh.setup()
slack_notify(":test_tube: Protocol started")
# ... your protocol goes here ...
# await lh.pick_up_tips(...)
# await lh.aspirate(...)
# await lh.dispense(...)
# await lh.drop_tips()
slack_notify(":white_check_mark: Protocol finished")
except Exception as e:
slack_notify(f":rotating_light: Protocol failed: `{e}`\n```{traceback.format_exc()}```")
raise
finally:
await lh.stop()
4. Richer messages with Block Kit#
text is just one of the payload fields. Slack also accepts Block Kit for headers, sections, fields, and dividers:
def slack_post(payload: dict, webhook_url: str | None = None, timeout: float = 5.0) -> None:
url = webhook_url or _load_webhook()
if not url:
print(f"[slack_post: no webhook configured at {WEBHOOK_FILE}] {payload}")
return
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
resp.read()
except (urllib.error.URLError, TimeoutError) as e:
print(f"[slack_post failed: {e}] {payload}")
slack_post({
"blocks": [
{"type": "header", "text": {"type": "plain_text", "text": "Run complete"}},
{"type": "section", "fields": [
{"type": "mrkdwn", "text": "*Plates:*\n4"},
{"type": "mrkdwn", "text": "*Duration:*\n00:42:13"},
]},
]
})
Notes#
The webhook URL lives in
~/.plr_slack_webhook, outside the repo. Anyone with the URL can post to that channel, so keep it mode 600.The helper catches
URLErrorandTimeoutErrorso a failed POST doesn’t abort the protocol.Incoming Webhooks rate-limit to ~1 msg/sec per channel.
Webhooks are write-only. Slack → robot (e.g. a
/pausecommand) needs the Events API.