Always-on agents · Same use cases, free tools

Build a Slack triage agent, the job OpenAI’s dots post describes, with free software

OpenAI's announcement of dots has one line I keep rereading: a bug appears in Slack, and dots immediately start investigating. That is a job, not a chat. Here is the same loop built with free software on a machine you own: Slack in through Socket Mode, a small local model to sort and summarise, and a human who presses Confirm before anything is filed.

In short

A Slack triage agent watches a support channel, decides whether each message is a bug, proposes an area, severity, owner and a question for the reporter, and posts that in the thread with Confirm and Dismiss buttons. Nothing is filed until a person confirms. Built with Slack Bolt in Socket Mode, Ollama with a 1.5B model for the judgement, and systemd to keep it alive.

Part of the Always-on agents series, post 10 of 33.

Let me be exact about what OpenAI showed and what it only described, because the two have been blurred in most of what I read about dots. The launch post, on 29 September 2026, says that inside OpenAI a bug appears in Slack, and dots immediately start investigating. That is a description of internal use, not the stage demo. The demo itself, as CBS News reported it, showed dots integrating into Slack and Teams to brainstorm, update calendars and review customer feedback. The developer example on the page turns customer feedback into tested fixes and pull requests; the invoice story, where a dot noticed a forgotten invoice and sent it after approval, came from an early tester.

So the bug-triage framing of this post is mine, taken from that one sentence, because it is the most useful always-on job I can imagine in a support channel and because it has a clean shape: a message arrives, a judgement is made, a proposal is posted, a human decides. I built it this afternoon in containers and I will tell you exactly which parts ran.

What the agent is allowed to do

This is the part I decide before writing code, every time. The agent gets a Slack bot token with three scopes: read the history of channels it is in, read channel names, and post messages. It gets an app-level token so it can connect in Socket Mode. It cannot delete, cannot DM people, cannot read channels it was not invited to. The model it calls runs on the same machine and sees only the message text. And the single action that changes anything outside Slack, filing an issue, runs only after a person presses a button.

OpenAI's page, to its credit, describes the same posture for dots: proactive work uses tools restricted to read-only, and custom rules decide what the dot may do alone, what needs approval, and what is blocked. The difference is that here the rules are forty lines of Python you can read.

Why Socket Mode

A Slack app normally receives events at a public HTTPS URL, which means a domain, a certificate, a reverse proxy and an open port on the machine running your agent. For a thing that lives on a Mac mini or a small VPS I think that is the wrong trade. Socket Mode turns it around: the app opens a WebSocket out to Slack, Slack pushes events down it, and nothing on your machine listens for inbound connections. Slack's own documentation recommends Bolt for it, and notes that Socket Mode apps cannot be listed in the public marketplace, which for an internal agent is irrelevant.

That also makes it a daemon rather than a scheduled job: the socket has to stay open. So this agent is a systemd service with Restart=always, not a timer, and the difference matters for the setup pages in this series. If you want the cron-shaped version instead, poll conversations.history every five minutes and skip Socket Mode entirely; the triage step is identical.

The judgement step: a small local model with a fixed shape

The model's whole job is to fill in a form: is this a bug, which area, how severe, a one-line summary, one question for the reporter. I use Ollama's structured output, which takes a JSON schema in the format field and refuses to return anything that does not match it. That one feature is what makes a 1.5-billion-parameter model usable here: it cannot ramble, cannot invent a field, and the area it picks has to be one of the ones I listed, each of which maps to a named owner.

triage.py#!/usr/bin/env python3
"""Triage step: a Slack message in, a proposal out. The model never acts; it fills in a form a human confirms.
Runs against a local Ollama (OLLAMA_URL, default http://localhost:11434) with a small model."""
import json, os, sys, urllib.request

OLLAMA = os.environ.get("OLLAMA_URL", "http://localhost:11434")
MODEL = os.environ.get("TRIAGE_MODEL", "qwen2.5:1.5b")
OWNERS = {"billing": "@dana", "video-pipeline": "@lera", "instagram-connect": "@sam",
          "website": "@lera", "unknown": "@lera"}

SCHEMA = {                      # Ollama's structured output: the reply must be exactly this shape
    "type": "object",
    "properties": {
        "is_bug": {"type": "boolean"},
        "area": {"type": "string", "enum": list(OWNERS)},
        "severity": {"type": "string", "enum": ["low", "medium", "high"]},
        "summary": {"type": "string"},
        "question_for_reporter": {"type": "string"},
    },
    "required": ["is_bug", "area", "severity", "summary", "question_for_reporter"],
}

PROMPT = """You triage messages from a company Slack channel for a small video software company.
Decide whether the message reports a bug. If it does, pick the product area, a severity, write a
one-sentence summary in neutral words, and one question that would help reproduce it. If it is not a
bug, set is_bug false, area unknown, severity low, and summarise what it is instead. Answer only with JSON.

Message from {user} in #{channel}:
{text}"""


def classify(msg):
    body = {"model": MODEL, "stream": False, "format": SCHEMA, "options": {"temperature": 0},
            "messages": [{"role": "user", "content": PROMPT.format(**msg)}]}
    req = urllib.request.Request(OLLAMA + "/api/chat", data=json.dumps(body).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=600) as r:
        out = json.load(r)
    verdict = json.loads(out["message"]["content"])
    verdict["proposed_owner"] = OWNERS.get(verdict["area"], OWNERS["unknown"])
    verdict["source"] = {"user": msg["user"], "channel": msg["channel"], "ts": msg["ts"]}
    verdict["status"] = "PROPOSED, waiting for a human"   # nothing happens until someone clicks Confirm
    return verdict


if __name__ == "__main__":
    for msg in json.load(open(sys.argv[1])):
        print(json.dumps(classify(msg), ensure_ascii=False))

Temperature zero, because I want the same message to get the same verdict tomorrow. The owner map is deliberately dumb: a dictionary, edited by hand. The moment I let the model choose owners from a free-text field I have an agent that can page the wrong person at 3am.

The Slack side

app.py#!/usr/bin/env python3
"""Slack side: listen in Socket Mode (no public URL), send each new message in the watched channels to
triage.classify, post the proposal back with Confirm / Not a bug buttons, and only when a human presses
Confirm file the issue. Needs SLACK_BOT_TOKEN (xoxb-...), SLACK_APP_TOKEN (xapp-...) and the bot in the channel."""
import json, os
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from triage import classify

WATCH = set(os.environ.get("TRIAGE_CHANNELS", "support").split(","))
app = App(token=os.environ["SLACK_BOT_TOKEN"])


@app.event("message")
def on_message(event, say, client):
    if event.get("subtype") or event.get("bot_id"):
        return                                  # edits, joins and bots: ignore
    info = client.conversations_info(channel=event["channel"])["channel"]
    if info["name"] not in WATCH:
        return
    v = classify({"user": event["user"], "channel": info["name"], "ts": event["ts"], "text": event["text"]})
    if not v["is_bug"]:
        return                                  # say nothing; a triage bot that chats is noise
    say(thread_ts=event["ts"], text="Triage proposal", blocks=[
        {"type": "section", "text": {"type": "mrkdwn", "text":
            "*Looks like a bug.* %s\n*Area:* %s  *Severity:* %s  *Owner:* %s\n*To reproduce, ask:* %s"
            % (v["summary"], v["area"], v["severity"], v["proposed_owner"], v["question_for_reporter"])}},
        {"type": "actions", "elements": [
            {"type": "button", "text": {"type": "plain_text", "text": "Confirm and file"}, "style": "primary",
             "action_id": "confirm", "value": json.dumps(v)},
            {"type": "button", "text": {"type": "plain_text", "text": "Not a bug"}, "action_id": "dismiss"}]},
    ])


@app.action("confirm")
def on_confirm(ack, body, client):
    ack()
    v = json.loads(body["actions"][0]["value"])
    url = file_issue(v)                         # the ONLY line that changes anything outside Slack
    client.chat_update(channel=body["channel"]["id"], ts=body["message"]["ts"], blocks=[],
                       text="Filed: %s (confirmed by <@%s>)" % (url, body["user"]["id"]))


@app.action("dismiss")
def on_dismiss(ack, body, client):
    ack()
    client.chat_update(channel=body["channel"]["id"], ts=body["message"]["ts"], blocks=[],
                       text="Dismissed by <@%s>." % body["user"]["id"])


def file_issue(v):
    with open("filed.jsonl", "a") as fh:          # stub: swap for your tracker's API call
        fh.write(json.dumps(v) + "\n")
    return "filed.jsonl#%s" % v["source"]["ts"]


if __name__ == "__main__":
    SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start()

Three design decisions in there. The bot ignores edits, joins and other bots, so it cannot loop on itself. It says nothing at all when the verdict is not a bug, because a triage bot that comments on lunch plans gets muted in a week. And the proposal is posted in the thread of the original message with two buttons, so the person who confirms is on the record, and the message is updated in place so a channel never fills with stale proposals.

What I actually ran

I do not have a throwaway Slack workspace with a bot installed on this machine, and I was not going to point an experimental agent at the real one. So the Slack listener above is written and reviewed but was not connected to Slack today; the Bolt pattern it uses is the documented Socket Mode pattern and nothing in it is exotic. What I did run, end to end, is the judgement step, which is the part that could actually go wrong: five made-up support messages, including a billing complaint, a lunch invitation and a caption-sync bug, through triage.py against a local model. I had meant to do that in a container too, but the Ollama image download kept failing on a busy afternoon, so the model ran under a standalone Ollama binary on my Mac instead; the script and the API call are identical either way.

messages.json[
  {"user": "priya", "channel": "support", "ts": "1760950001.0001",
   "text": "Since this morning the Instagram connect button just spins. Tried two browsers. Our account is @northstar.coffee, on the Growth plan."},
  {"user": "marco", "channel": "support", "ts": "1760950002.0002",
   "text": "Invoice for October shows 3 seats but we only have 2 users now, can someone fix the amount before it charges on Friday?"},
  {"user": "jules", "channel": "general", "ts": "1760950003.0003",
   "text": "Anyone fancy a team lunch on Thursday? Thinking the Lebanese place."},
  {"user": "ana", "channel": "support", "ts": "1760950004.0004",
   "text": "The clip that got scheduled last night has captions that are a few seconds behind the speaker, first time I've seen this. Post is still in drafts so no harm yet."},
  {"user": "tom", "channel": "support", "ts": "1760950005.0005",
   "text": "Pricing page loads blank on Safari on my iPad, works fine on my laptop."}
]
Output, 20 October 2026, qwen2.5:1.5b on CPU, five messages in 4 min 25 s{"is_bug": true, "area": "website", "severity": "medium", "summary": "Instagram connect button not working", "question_for_reporter": "Can you provide steps to reproduce the issue?", "proposed_owner": "@lera", "source": {"user": "priya", "channel": "support", "ts": "1760950001.0001"}, "status": "PROPOSED, waiting for a human"}
{"is_bug": false, "area": "billing", "severity": "low", "summary": "Invoice amount for October is incorrect.", "question_for_reporter": "Please confirm the correct number of seats and users.", "proposed_owner": "@dana", "source": {"user": "marco", "channel": "support", "ts": "1760950002.0002"}, "status": "PROPOSED, waiting for a human"}
{"is_bug": false, "area": "unknown", "severity": "low", "summary": "Request for team lunch on Thursday", "question_for_reporter": "What is the preferred location for the team lunch on Thursday?", "proposed_owner": "@lera", "source": {"user": "jules", "channel": "general", "ts": "1760950003.0003"}, "status": "PROPOSED, waiting for a human"}
{"is_bug": false, "area": "video-pipeline", "severity": "low", "summary": "User reports a video clip with captions that are delayed.", "question_for_reporter": "Please provide the video file and the expected and actual captions to help reproduce the issue.", "proposed_owner": "@lera", "source": {"user": "ana", "channel": "support", "ts": "1760950004.0004"}, "status": "PROPOSED, waiting for a human"}
{"is_bug": true, "area": "website", "severity": "low", "summary": "Pricing page loads blank on Safari on iPad, works fine on laptop.", "question_for_reporter": "Can you please check if the pricing page loads correctly in Safari on iPad?", "proposed_owner": "@lera", "source": {"user": "tom", "channel": "support", "ts": "1760950005.0005"}, "status": "PROPOSED, waiting for a human"}

I am glad I ran it rather than describing it, because the result is more useful than a clean demo would have been. Every reply matched the schema, every area was one from my list, and the lunch invitation was correctly left alone. But a 1.5-billion-parameter model is a sorter, not a judge. It called the spinning Instagram button a bug but filed it under website rather than instagram-connect, so the proposal would have gone to me instead of the person who owns that integration. It called the delayed captions not a bug, with the right area and a sensible reproduction question, which is the most dangerous kind of miss: a real defect that the bot would have stayed silent about. And it took four and a half minutes for five messages on CPU, which is fine for a support channel and hopeless for a busy one.

Two conclusions. The Confirm button is doing real work, which is the whole argument of this series: an unattended judgement needs a human on the far side of it. And the not a bug branch should not be silent at this model size; I would post the proposal in-thread either way and let the Dismiss button carry the no. A 7B model, or a paid API for the judgement step only, would fix most of this at a cost I can measure per message, and that is the trade I would make before letting the bot stay quiet.

Keeping it alive

This is the Linux core page pattern: a dedicated user, a venv, secrets in a root-owned environment file, a service that restarts. Ollama runs as its own service on the same box, listening only on localhost.

/etc/systemd/system/triage.service[Unit]
Description=Slack triage agent
After=network-online.target
Wants=network-online.target

[Service]
User=agent
WorkingDirectory=/opt/triage
EnvironmentFile=/etc/triage/env
ExecStart=/opt/triage/venv/bin/python /opt/triage/app.py
Restart=always
RestartSec=5
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/opt/triage

[Install]
WantedBy=multi-user.target
Terminalsudo systemctl daemon-reload
sudo systemctl enable --now triage.service
journalctl -u triage.service -f

On a Mac the same thing is a launchd agent with KeepAlive set, which is how my own jobs run; the Mac guide has the file. Memory is the number to watch: a 1.5B model on CPU wants about two gigabytes while it thinks, which is fine on a Mac mini and tight on the smallest VPS tiers.

Where this sits against dots

What dots has that this does not: a cloud computer with a browser, 4,000 app connections, and a model that can go and reproduce the bug rather than ask a question about it. What this has that dots does not: a bill of zero, a permission list you can read in one screen, and a verdict that is the same every time. For a five-person support channel I would run this. For a company where the agent should actually fix the bug, dots or something like it is the honest answer, and I would still want the Confirm button. The next build in the series moves from proposing to acting, with an inbox and calendar agent that can only ever draft.

Sources

  1. OpenAI, Introducing dots (accessed October 20, 2026)
  2. CBS News, Sam Altman unveils dots, OpenAI's new AI personal agent (accessed October 20, 2026)
  3. Slack, Using Socket Mode (accessed October 20, 2026)
  4. Slack, Bolt for Python (accessed October 20, 2026)
  5. Ollama, Structured outputs (accessed October 20, 2026)

Written by Lera Leonteva. The classification step was run once on 20 October 2026 with qwen2.5:1.5b under a standalone Ollama 0.40.6 binary on my Mac, against the five messages shown; the Slack listener was written and reviewed but not connected to a workspace that day.

Frequently asked questions

Did OpenAI demo a Slack bug-triage dot?

Not on stage. The dots announcement describes, as internal use, a bug appearing in Slack and dots starting to investigate. The public demo showed Slack and Teams integration for brainstorming, calendar updates and reviewing customer feedback, per CBS News. This post takes the sentence from the announcement and builds that job.

Why use Socket Mode instead of a webhook?

Because it needs no public URL. The agent opens a WebSocket out to Slack and Slack pushes events down it, so a machine behind a home router or on a VPS with every inbound port closed can run it. Slack recommends Bolt for Socket Mode; the trade-off is that such apps cannot be listed in the public marketplace.

Can a 1.5B local model really triage bugs?

For the narrow job here, deciding bug or not, picking from a fixed list of areas and severities, and writing one sentence, yes, provided you constrain it with a JSON schema through Ollama's structured output and set temperature to zero. It cannot investigate the bug; it fills in a form a human checks.

What stops the agent from filing nonsense?

A person. The proposal is posted with Confirm and Dismiss buttons and nothing is filed until someone presses Confirm. The owner map is a hand-edited dictionary, not a model choice, so it cannot page the wrong person.

What Slack scopes does it need?

channels:history and channels:read on the bot token to read the channels it is invited to, chat:write to post proposals, and connections:write on an app-level token for Socket Mode. Nothing that deletes, nothing that DMs, no channels it was not invited to.

Lera Leonteva
Lera Leonteva
CEO & Founder, Leo AI

Lera Leonteva is the founder and CEO of Leo AI, where she works on turning long-form footage into short-form video that earns attention on its own merits. She writes Video Scaling Strategies, taking one piece of content that worked and one that did not, and using what the market is doing to explain the gap.

The always-on agent I run for social video

Leo watches what your audience is responding to, picks a clip from footage you already have, edits it and books it on the calendar. Every hour, without being asked. It is the agent this whole series grew out of.

Keep going with the series

Every post in the Always-on agents series is on one page, newest first. If you would rather see the agent I run for social video, here it is.