SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor

Self-host OpenTag for @agent mentions

Run OpenTag on a VPS so Slack and GitHub mentions reach your coding agent: TLS ingress, webhook signature checks, token scopes, and safe defaults.

What OpenTag does when you mention an agent

OpenTag turns an @mention in a Slack thread or a GitHub issue into a coding agent run on a machine you own. Someone comments @opentag investigate this on an issue. A listener receives the platform event, checks its signature, matches the mention to a bound project, starts a coding agent against a local checkout, and posts the result back in the same thread.

The project is MIT licensed and lives at amplifthq/opentag. As of August 2026 the newest tagged release is v0.9.0, published on 28 July 2026, and it ships as an npm package. There is no official container image, so the thing you pin is the npm version. Every command below pins it.

This becomes a VPS project rather than a laptop project because of the GitHub side. GitHub delivers repository events by making an HTTP request to a URL you register once, so that URL has to answer at the same address tomorrow.

The four moving parts

The listener receives platform events, and each platform has its own. The GitHub listener is an HTTP endpoint on port 3050 at the path /github/webhooks. The Slack Events API listener is on port 3040 at /slack/events. Slack can also run in Socket Mode, where the app opens an outbound WebSocket and needs no inbound port at all.

The dispatcher is the coordinator. It listens on port 3030 by default, keeps run state in a local database file set by OPENTAG_DATABASE_PATH, and records an audit trail for every run. Nothing outside the box should ever reach this port.

The runner is the local daemon. It polls for work, claims a run, holds a lease on it, and sends a heartbeat every 15 seconds by default while the run is alive. It refuses any claimed run whose project target is missing or outside the allowlist in its own config, which is the check that stops a GitHub event from pointing your agent at a repository you never bound.

The executor is the coding agent itself. OpenTag launches it over ACP (agent client protocol), a JSON-RPC protocol spoken over standard input and output, so the agent runs as a child process inside a working directory OpenTag hands it. Built-in names include echo, codex, claude-code, cursor, opencode, hermes and openclaw. Start with echo, the executor the example config ships with, because it proves the whole path works before a model touches your code.

The order never changes: platform event, signature check, run record, claim, agent, reply in the thread.

Why a laptop and a tunnel are not enough

The GitHub setup guide tells you to run ngrok http 3050 and paste the tunnel host into the repository webhook. That works for the first ten minutes. A free tunnel host changes every time the process restarts, and it stops existing when the laptop sleeps. GitHub keeps the old payload URL and keeps trying it, so the Recent Deliveries tab in the webhook settings fills with failures while the thread stays silent. Nobody notices for a week, because a webhook that does nothing looks exactly like a bot nobody mentioned.

A VPS fixes the two things that break. The DNS name does not change, so the payload URL you paste once stays correct. The machine does not sleep, so a comment at 02:00 gets an answer. Set the box up properly first: the first ten minutes on a new VPS covers the login user and the firewall this guide assumes.

Slack is the exception. In Socket Mode it connects outward and needs no public URL, so a Slack-only deployment can stay closed. GitHub has no equivalent. Repository webhooks are inbound HTTP, which means a public endpoint, which means TLS (transport layer security) and a signature check.

Self-host OpenTag on Ubuntu from a pinned release

OpenTag v0.9.0 requires Node.js 22 or newer. Ubuntu 24.04 ships Node 18 in its own repository, so install from NodeSource.

curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt install -y nodejs
node -v

node -v must print v22 or higher. On Node 20 the install prints an EBADENGINE warning and the CLI can fail once it starts.

Give the service its own account. The agent runs with this user's permissions, so it should not be your login and it should not be root. Least privilege users on a VPS covers why that separation is worth the extra step.

sudo adduser --disabled-password --gecos "" opentag
sudo loginctl enable-linger opentag
sudo npm install -g @opentag/cli@0.9.0
command -v opentag

command -v opentag should print a path such as /usr/bin/opentag. The linger setting matters on Linux: OpenTag installs its background service through systemd, and a user service without lingering stops the moment your SSH session closes.

Run setup as that user.

sudo -iu opentag opentag setup

Setup asks six things: the CLI language, the local listening address, the coding agent, the local project to work on, the platform credentials to save, and how to run. Keep the listening address on 127.0.0.1, because nginx terminates TLS and forwards to it, so the listeners never need to be reachable from outside. For GitHub it also asks for the repository in owner/repo form, whether it may open pull requests, the webhook port (3050 by default) and the token. Choose background service mode at the end. If you already have a config and want the service installed without prompts, opentag setup --service does that.

Config lands in /home/opentag/.config/opentag/config.json and runtime state in /home/opentag/.local/state/opentag. These keys are worth checking by hand after setup writes the file.

{
  "runnerId": "runner_local",
  "dispatcherUrl": "http://localhost:3030",
  "runnerToken": "...",
  "approvalMode": "ask",
  "repositories": []
}

Prefer runnerToken, the runner-scoped bearer token, over the older shared pairingToken. The config file holds credentials in plain text unless you replace them with a secret reference, which reads the value from the environment or from a file on disk at startup. Either way this file is the most sensitive thing on the box: mode 600, owned by opentag, and never inside a git repository. The wider argument is in keeping secrets out of AI agents.

Check the install before exposing anything.

sudo -iu opentag opentag doctor
sudo -iu opentag opentag status

opentag doctor checks the dispatcher, the bindings, the checkouts and the executors. opentag status prints the config and runtime state, and it can be scoped to a single run once runs exist. Fix everything doctor reports before you point a platform at this box.

Put TLS in front and open only two paths

nginx terminates TLS and forwards exactly two paths. Everything else returns 404, so a scanner that finds the host learns nothing about what runs behind it.

Write a plain port 80 server block at /etc/nginx/sites-available/opentag with the two locations below, then let Certbot add the TLS half.

sudo apt install -y nginx certbot python3-certbot-nginx
sudo ln -s /etc/nginx/sites-available/opentag /etc/nginx/sites-enabled/opentag
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d opentag.example.com

nginx -t prints syntax is ok and test is successful, and it is the only thing standing between a typo and a reload that drops the site. Certbot on Ubuntu 24.04 with nginx covers renewal and the ways an ACME (automatic certificate management environment) challenge fails. The finished block looks like this.

server {
    listen 443 ssl;
    server_name opentag.example.com;

    ssl_certificate     /etc/letsencrypt/live/opentag.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/opentag.example.com/privkey.pem;

    client_max_body_size 2m;

    location = /github/webhooks {
        proxy_pass http://127.0.0.1:3050;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
    }

    location = /slack/events {
        proxy_pass http://127.0.0.1:3040;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
    }

    location / {
        return 404;
    }
}

The = in location = /github/webhooks is an exact match, and proxy_pass with nothing after the port passes the original URI through unchanged. Drop the = and every path under /github/webhooks/ is forwarded too, which is more surface than the listener needs.

The firewall stays narrow.

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status

Ports 3030, 3040 and 3050 are never opened. Confirm they are bound to loopback rather than to every interface.

sudo ss -tlnp

Every OpenTag line should read 127.0.0.1:3030 or similar. A line reading 0.0.0.0:3050 means the listener is offering itself to the whole internet and only ufw is stopping it, which is one firewall mistake away from an open agent trigger. ufw firewall basics explains what that default deny is really doing.

Two checks prove the front door. curl -I https://opentag.example.com/ returns 404 from nginx, which shows the certificate is valid and the catch-all is closed. A request to /slack/events or /github/webhooks carrying no signature must never return 200.

Verify every signature, because the URL is public

Anyone can find the payload URL. It sits in your repository settings, in browser history, in a screenshot pasted into a ticket. The signature is the only thing that separates a real GitHub delivery from a request someone typed by hand.

GitHub signs each delivery with the webhook secret and sends the result in the x-hub-signature-256 header. OpenTag verifies that header against platforms.github.webhookSecret. The project's hardening notes state the rule directly: do not accept unsigned source events on /github/webhooks. Slack signs each request with SLACK_SIGNING_SECRET and includes a timestamp, so a captured body cannot be replayed hours later.

Skipping this is not a small risk. An unverified endpoint accepts a hand-written issue_comment payload containing @opentag, and OpenTag then runs a coding agent, with your token, in your checkout, on instructions from a stranger. The reply goes to whatever thread the fake payload names.

OpenTag adds two layers on top. Source deliveries are tracked by delivery ID, so redelivering the same event does not start a second run. Runner calls accept idempotency keys, so replaying one returns success without appending another audit event.

Rate limits are configurable and belong on. OPENTAG_RATE_LIMIT_WINDOW_MS and OPENTAG_RATE_LIMIT_MAX_REQUESTS bound the request rate, OPENTAG_MAX_REQUEST_BODY_BYTES bounds the body, and an oversized payload is rejected with 413 request_body_too_large. OPENTAG_RATE_LIMIT_DISABLED=true exists for local development, and it has no place on a public box. One more rule from the same notes: a public relay URL must use HTTPS, and the CLI allows plain HTTP only for localhost.

What token scopes does the bot actually need?

On GitHub, OpenTag uses a fine-grained personal access token rather than a GitHub App. The docs say the App path is planned and is not the default CLI setup today, and that has a consequence people miss: the bot comments as the human who created the token. Create it under an account you are willing to see quoted in every triage reply.

Scope it as tightly as the setup guide does. Choose Only select repositories and pick one. Grant Issues: Read and write and Pull requests: Read and write. That is enough to read a mention and answer in the thread.

Notice what is missing: write access to code. OpenTag does not push branches unless preparePullRequestBranch is set to true, and a separate githubApplyToken exists so the token that writes code is not the token that writes comments. Keep them apart, and keep the write token off until the read-and-comment path has run for a few weeks.

The configuration to avoid is a token with Contents: Read and write across All repositories. Everyone who can comment on any of those repositories can now steer an agent that has commit rights, and the audit trail says the token owner did it. Widen the scope one repository at a time, after the agent has earned it.

On Slack the bot scopes are app_mentions:read, chat:write, reactions:write and channels:history. Private channels also need groups:history plus a subscription to the message.groups event. Socket Mode needs an app-level token with connections:write, the one that starts with xapp-. channels:history reads message history in the public channels the bot has been added to, so add the bot to the channels where it is wanted rather than everywhere.

Route one issue end to end

The webhook comes first. In the repository, open Settings, then Webhooks, then Add webhook. The payload URL is https://opentag.example.com/github/webhooks, the content type is application/json, and the secret is the one setup generated. Subscribe to Issue comments and Pull request review comments, and nothing else.

GitHub sends a ping delivery as soon as you save. Open Recent Deliveries and check the request reached the server at all. A 502 there is nginx saying it could not reach the listener, which is a local problem, not a GitHub one.

Now use it. Open an issue that describes a bug and comment:

@opentag triage this. Reproduce the report against the current main branch, then reply with the file and function most likely responsible, plus the test you would write first.

What should happen, in order. Recent Deliveries records the issue_comment delivery with a 2xx response. The dispatcher records a run. The runner claims it and starts heartbeating. The executor opens the checkout and works. The answer arrives as a comment in the same issue thread. sudo -iu opentag opentag status shows the run while it is in flight, so you can watch it instead of guessing.

Set approvalMode to ask before the first real run. In ask mode the run pauses and waits for a person before it does anything that changes state. The auto and autonomous modes exist, and they are reasonable later, on a repository where you have read a month of transcripts.

On the Slack side the same run starts with /bind owner/repo in the channel, then a mention. The bot also answers /help, /status, /doctor, /stop and /unbind confirm. Restrict who may change bindings with OPENTAG_SLACK_BINDING_ADMIN_USER_IDS, a comma-separated list of Slack user IDs, because a binding is the mapping from a public channel to a checkout on your server.

Triage is a good first route because it reads and does not write, and the answer is easy to grade. Review is the next step up, where the agent comments on a diff instead of an issue: a self-hosted pull request review agent is this same architecture pointed at pull requests. If you want the agent to reach your own systems while it works, that is the job of MCP servers on a VPS.

What happens when the agent is wrong in front of everyone?

It will be wrong. The question is what that costs.

A wrong reply on a public issue is a comment under a name your team recognises, and GitHub emails it to everyone subscribed the moment it posts. Deleting the comment does not recall the email. The same is true of a Slack notification. Plan for the answer being wrong in public rather than for it being right in private.

Four choices bound the damage, and they matter more than any prompt you write.

  • Run in ask mode, so the agent proposes, a person approves, and a wrong plan costs one click.
  • Leave preparePullRequestBranch at its default of false, so the worst outcome of a bad run is a wrong comment rather than a wrong branch.
  • Bind one repository and one channel to start. The runner rejects any run whose project target sits outside its local allowlist, so an unbound repository cannot pull the agent into itself.
  • Keep the commenting token separate from any apply token, so revoking write access does not take triage down with it.

Slack has a /stop command for a run that is going the wrong way. Every run also leaves an audit record holding the mention that started it and what the agent did, which is what you read afterwards to work out where it went wrong.

The social half matters as much as the config. Put the bot in one channel where people expect a machine and know it can be wrong. A confident wrong answer in a channel of forty people who assume a human reviewed it costs more than the triage saved. Write in the channel description who owns the bot and who checks its output.

Backups, upgrades and the pin

Two paths hold everything: /home/opentag/.config/opentag/config.json and /home/opentag/.local/state/opentag. The first has your credentials, the second has run history and the database file. Back both up with mode 600, kept off the box. Losing them means recreating tokens and bindings, not rebuilding a server.

Upgrades are a version bump and a restart.

sudo npm install -g @opentag/cli@0.9.0
sudo -iu opentag opentag service stop
sudo -iu opentag opentag service start
sudo -iu opentag opentag doctor

Pin the version rather than tracking @latest. This software runs a coding agent against your repository with a live token, so a release published overnight is an unreviewed change to that. The security policy backports nothing, and fixes land only in the newest release, so pinning means you read the changelog and move on purpose. It does not mean staying on v0.9.0 forever. The history through July 2026 shows several releases a month, which is a good reason to read release notes before each bump.

FAQ

Do I need a VPS to run OpenTag, or is a laptop enough?

A laptop is enough for Slack alone, because Socket Mode opens an outbound WebSocket and needs no inbound port. GitHub is different. Repository webhooks deliver over inbound HTTP to a URL you register once, so the address must stay the same and must answer while you sleep. A tunnel host from a free account changes on every restart, and GitHub keeps posting to the old one, which shows up as failed entries in the repository Recent Deliveries tab and as silence in the thread. A VPS with a fixed DNS name and a certificate removes both problems.

Which GitHub permissions does OpenTag need?

A fine-grained personal access token limited to Only select repositories, with Issues: Read and write and Pull requests: Read and write. That covers reading a mention and replying in the thread. Write access to code is not needed unless you set preparePullRequestBranch to true so OpenTag pushes branches, and a separate githubApplyToken exists so the code-writing token stays apart from the commenting one. Avoid an all-repositories token with contents write, because anyone who can comment on any of those repositories could then steer an agent that can commit.

How do I stop a run that is going wrong?

Slack has a /stop command for exactly this. On the server, opentag status shows what is running, and opentag service stop stops the daemon, which ends the whole pipeline rather than one run. To avoid needing either, set approvalMode to ask so runs pause for a person before they change anything, and leave preparePullRequestBranch at false so a bad run produces a comment instead of a branch.

Why does my webhook return 502 while the thread stays silent?

502 comes from nginx, not from OpenTag, and it means the proxy could not reach the listener. /var/log/nginx/error.log will show connect() failed (111: Connection refused) while connecting to upstream. Either the listener is stopped, or it is on a different port than the proxy_pass line names. Run sudo ss -tlnp and confirm something is listening on 127.0.0.1:3050 for GitHub and 127.0.0.1:3040 for Slack, then run opentag doctor for the bindings and executors.

#opentag#ai-agents#slack#github#webhooks#self-hosting