How to Self-Host OpenTag for @agent Mentions
Run OpenTag v0.9.0 on your VPS so Slack and GitHub @mentions reach your coding agent, with TLS, webhook signatures, token scopes, and safe defaults.
Wetin OpenTag dey do when you mention an agent
OpenTag dey turn @mention for Slack thread or GitHub issue into coding agent run for machine wey you own. Person comment @opentag investigate this for issue. Listener dey receive platform event, check the signature, match the mention with project wey dem bind, start coding agent against local checkout, then post result back for the same thread.
The project get MIT license and e dey for amplifthq/opentag. As of August 2026, the newest tagged release na v0.9.0, wey dem publish on 28 July 2026, and e dey ship as npm package. No official container image dey, so na the npm version you go pin. Every command below dey pin am.
This one become VPS project instead of laptop project because of the GitHub side. GitHub dey deliver repository events by making HTTP request go URL wey you register once, so that URL must answer for the same address tomorrow.
The four moving parts
The listener na dey receive platform events, and every platform get im own listener. The GitHub listener na HTTP endpoint for port 3050 at path /github/webhooks. The Slack Events API listener dey for port 3040 at /slack/events. Slack fit also run for Socket Mode, where the app go open outbound WebSocket and e no need inbound port at all.
The dispatcher na the coordinator. By default, e dey listen for port 3030, keep run state for local database file wey OPENTAG_DATABASE_PATH set, and record audit trail for every run. Nothing outside the box suppose ever reach this port.
The runner na the local daemon. E dey poll for work, claim run, hold lease for am, and send heartbeat every 15 seconds by default while the run still dey alive. E go reject any claimed run wey project target dey missing or dey outside allowlist for im own config. Na this check stop GitHub event from pointing your agent to repository wey you never bind.
The executor na the coding agent itself. OpenTag dey launch am over ACP (agent client protocol), wey be JSON-RPC protocol wey dey communicate through standard input and output. So the agent dey run as child process inside working directory wey OpenTag give am. Built-in names include echo, codex, claude-code, cursor, opencode, hermes and openclaw. Start with echo, wey be the executor wey example config ships with, because e go prove say the whole path dey work before model touch your code.
The order no dey change: platform event, signature check, run record, claim, agent, reply for the thread.
Why laptop and tunnel no dey enough
The GitHub setup guide tell you to run ngrok http 3050 and paste the tunnel host inside the repository webhook. E go work for the first ten minutes. Free tunnel host dey change every time the process restart, and e stop to exist when the laptop sleep. GitHub keep the old payload URL and continue to try am, so Recent Deliveries tab for webhook settings go fill with failures while the thread remain silent. Nobody go notice for one week, because webhook wey no dey do anything look exactly like bot wey nobody mention.
VPS fix the two things wey dey cause the problem. DNS name no dey change, so the payload URL wey you paste once remain correct. Machine no dey sleep, so comment wey enter for 02:00 go get answer. Set up the box correctly first: the first ten minutes for new VPS cover the login user and firewall wey this guide assume.
Slack na the exception. For Socket Mode e connect outward and e no need public URL, so Slack-only deployment fit remain closed. GitHub no get equivalent. Repository webhooks na inbound HTTP, wey mean say you need public endpoint, and that one mean TLS (transport layer security) plus signature check.
Self-host OpenTag on Ubuntu from a pinned release
OpenTag v0.9.0 need Node.js 22 or newer. Ubuntu 24.04 get Node 18 for im own repository, so install am 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 -vnode -v suppose print v22 or higher. For Node 20, the install dey print EBADENGINE warning, and the CLI fit fail once e start.
Give the service im own account. The agent dey run with this user permissions, so e no suppose be your login and e no suppose be root. Least privilege users on a VPS explain why this separation 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 opentagcommand -v opentag suppose print a path like /usr/bin/opentag. The linger setting important for Linux: OpenTag dey install im background service through systemd, and user service without lingering dey stop immediately your SSH session close.
Run setup as that user.
sudo -iu opentag opentag setupSetup dey ask six things: the CLI language, the local listening address, the coding agent, the local project wey e go work on, the platform credentials to save, and how to run am. Keep the listening address for 127.0.0.1, because nginx dey terminate TLS and forward request go there, so the listeners no need reach from outside. For GitHub, e still dey ask for the repository in owner/repo form, whether e fit open pull requests, the webhook port (3050 by default), and the token. Choose background service mode for the end. If you already get config and you want install the service without prompts, opentag setup --service go do am.
Config dey enter /home/opentag/.config/opentag/config.json and runtime state dey enter /home/opentag/.local/state/opentag. These keys worth checking by hand after setup write the file.
{
"runnerId": "runner_local",
"dispatcherUrl": "http://localhost:3030",
"runnerToken": "...",
"approvalMode": "ask",
"repositories": []
}Prefer runnerToken, the runner-scoped bearer token, instead of the older shared pairingToken. The config file dey keep credentials as plain text unless you replace dem with a secret reference, wey dey read the value from the environment or from a file for disk when startup happen. Either way, this file na the most sensitive thing for the box: mode 600, owned by opentag, and never inside a git repository. The wider explanation dey for keeping secrets out of AI agents.
Check the install before you expose anything.
sudo -iu opentag opentag doctor
sudo -iu opentag opentag statusopentag doctor dey check the dispatcher, the bindings, the checkouts, and the executors. opentag status dey print the config and runtime state, and e fit target one run after runs don exist. Fix everything wey doctor report before you point any platform go this box.
TLS dey in front and open only two paths
nginx dey terminate TLS and forward exactly two paths. Every other thing go return 404, so scanner wey find the host no go learn wetin dey run behind am.
Write plain port 80 server block for /etc/nginx/sites-available/opentag with the two locations below, then make Certbot add the TLS part.
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.comnginx -t dey print syntax is ok and test is successful, and na the only thing wey dey between typo and reload wey fit bring the site down. Certbot for Ubuntu 24.04 with nginx explain renewal and the ways ACME (automatic certificate management environment) challenge fit fail. The complete block look 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 = for location = /github/webhooks na exact match, and proxy_pass with nothing after the port dey pass the original URI through unchanged. If you remove =, every path under /github/webhooks/ go forward too, and that na more surface than the listener need.
The firewall remain narrow.
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw statusPorts 3030, 3040 and 3050 no dey open. Confirm say dem bind to loopback, no be every interface.
sudo ss -tlnpEvery OpenTag line suppose read 127.0.0.1:3030 or something similar. Line wey read 0.0.0.0:3050 mean say the listener dey offer itself to the whole internet, and na only ufw dey stop am. One firewall mistake fit turn am into open agent trigger. ufw firewall basics explain wetin that default deny really dey do.
Two checks go prove the front door. curl -I https://opentag.example.com/ go return 404 from nginx, showing say the certificate valid and the catch-all closed. Request to /slack/events or /github/webhooks wey carry no signature must never return 200.
Verify every signature, because anybody fit see the URL
Anybody fit find the payload URL. E dey your repository settings, browser history, or screenshot wey person paste inside ticket. The signature na the only thing wey separate real GitHub delivery from request wey person type by hand.
GitHub dey sign every delivery with webhook secret and send the result inside x-hub-signature-256 header. OpenTag dey verify that header against platforms.github.webhookSecret. The project hardening notes state the rule direct: make you no accept unsigned source events for /github/webhooks. Slack dey sign every request with SLACK_SIGNING_SECRET and include timestamp, so captured body no fit replay hours later.
If you skip this, the risk no small. Endpoint wey no verify signature go accept hand-written issue_comment payload wey contain @opentag. OpenTag go then run coding agent, with your token, inside your checkout, based on instruction from stranger. The reply go enter any thread wey fake payload name.
OpenTag add two layers on top. Source deliveries dey track by delivery ID, so redelivering the same event no go start another run. Runner calls dey accept idempotency keys, so replaying one go return success without adding another audit event.
You fit configure rate limits, and dem suppose dey enabled. OPENTAG_RATE_LIMIT_WINDOW_MS and OPENTAG_RATE_LIMIT_MAX_REQUESTS set the request rate limit, OPENTAG_MAX_REQUEST_BODY_BYTES set the body size limit, and oversized payload go reject with 413 request_body_too_large. OPENTAG_RATE_LIMIT_DISABLED=true dey for local development, and e no get place for public box. Another rule from the same notes be say public relay URL must use HTTPS, while the CLI allow plain HTTP only for localhost.
Wetin token scopes bot really need?
For GitHub, OpenTag dey use fine-grained personal access token instead of GitHub App. Docs talk say App option dey planned and e no be the default CLI setup for now. This get one effect wey people dey miss: bot go comment as the human wey create the token. Create am under account wey you no mind seeing quoted for every triage reply.
Set the scope as setup guide talk. Choose Only select repositories and pick one. Grant Issues: Read and write and Pull requests: Read and write. This one enough to read mention and answer inside the thread.
Notice wetin no dey there: write access to code. OpenTag no dey push branches unless preparePullRequestBranch set to true. Separate githubApplyToken dey so the token wey writes code no be the same token wey writes comments. Keep dem separate, and leave the write token off until the read-and-comment path don run for some weeks.
The configuration wey you suppose avoid na token with Contents: Read and write across All repositories. Anybody wey fit comment for any of those repositories fit now direct agent wey get commit rights, and audit trail go show say token owner do am. Widen the scope one repository at a time, after agent don prove say e deserve am.
For Slack, bot scopes na app_mentions:read, chat:write, reactions:write and channels:history. Private channels also need groups:history plus subscription to the message.groups event. Socket Mode need app-level token with connections:write, the one wey start with xapp-. channels:history dey read message history for public channels wey bot don join, so add bot to the channels wey e suppose use instead of adding am everywhere.
Route one issue end to end
Webhook na the first step. For the repository, open Settings, then Webhooks, then Add webhook. Payload URL na https://opentag.example.com/github/webhooks, content type na application/json, and secret na the one setup generate. Subscribe to Issue comments and Pull request review comments only.
GitHub go send ping delivery immediately after you save. Open Recent Deliveries and check whether the request reach the server at all. If e return 502, na nginx dey say e no fit reach the listener. This na local problem, no be GitHub problem.
Now use am. Open issue wey describe 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.This na wetin suppose happen, in order. Recent Deliveries records the issue_comment delivery with 2xx response. Dispatcher records one run. Runner claims am and starts heartbeating. Executor opens checkout and starts work. Answer comes as comment for the same issue thread. sudo -iu opentag opentag status shows the run while e still dey run, so you fit monitor am instead of guessing.
Set approvalMode to ask before the first real run. For ask mode, the run pauses and waits for person before e do anything wey go change state. auto and autonomous modes dey available, and dem reasonable later, for repository wey you don read one month of transcripts from.
For Slack side, the same run starts with /bind owner/repo for the channel, then a mention. The bot also answers /help, /status, /doctor, /stop and /unbind confirm. Restrict who fit change bindings with OPENTAG_SLACK_BINDING_ADMIN_USER_IDS, wey be comma-separated list of Slack user IDs, because binding na the mapping from public channel to checkout for your server.
Triage na good first route because e reads and e no write, and e easy to grade the answer. Review na the next level, where the agent comments on diff instead of issue: self-hosted pull request review agent na this same architecture wey dem point to pull requests. If you want the agent to reach your own systems while e dey work, na the work of MCP servers on a VPS. Web search na the other capability wey triage dey ask for, and wiring the agent to your own SearXNG instance keeps those lookups on hardware wey you dey run, but e adds one more channel through which stranger text fit reach the agent.
Agent dey wrong for front of everybody, wetin go happen?
E go happen. The question na wetin e go cost.
Wrong reply for public issue na comment under name wey your team sabi, and GitHub go email everybody wey subscribe immediately e post am. If you delete the comment, e no fit recall the email. Same thing apply to Slack notification. Plan for answer wey fit wrong in public, instead make you assume say e go correct in private.
Four choices dey limit the damage, and dem matter pass any prompt wey you write.
- Run for
askmode, so agent go propose, person go approve, and wrong plan go cost just one click. - Leave
preparePullRequestBranchfor the default value of false, so the worst result of bad run na wrong comment, no be wrong branch. - Start with one repository and one channel. Runner go reject any run wey project target dey outside its local allowlist, so unbound repository no fit pull agent enter itself.
- Keep commenting token separate from any apply token, so if you revoke write access, triage no go stop with am.
Slack get /stop command for run wey dey go wrong direction. Every run still leave audit record wey hold the mention wey start am and wetin agent do. Na this record you go read later to find out where e go wrong.
The social side matter as much as the config. Put the bot for one channel where people expect machine and know say e fit wrong. Confident wrong answer for channel of forty people wey assume say human don review am go cost pass the triage wey e save. Write for the channel description who own the bot and who dey check its output.
Backups, upgrades and pinning
Na two paths hold everything: /home/opentag/.config/opentag/config.json and /home/opentag/.local/state/opentag. The first one get your credentials, while the second one get run history and the database file. Back up both with mode 600, and keep the backups outside the server. If you lose them, you go recreate tokens and bindings; you no need rebuild the server.
Upgrade na to bump the version and 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 doctorPin the version instead of tracking @latest. This software dey run coding agent against your repository with live token, so any release wey dem publish overnight na unreviewed change to that setup. The security policy no backport anything, and fixes dey enter only the newest release. So, pinning mean say you read the changelog and upgrade intentionally. E no mean say you go remain on v0.9.0 forever. The history through July 2026 show several releases every month, and that na good reason to read release notes before each version bump.
FAQ
OpenTag run need VPS, or laptop go do?
Laptop fit handle Slack alone, because Socket Mode dey open outbound WebSocket and e no need inbound port. GitHub different. Repository webhooks dey deliver through inbound HTTP go URL wey you register once, so the address must remain the same and must answer even when you dey sleep. Tunnel host from free account dey change every time e restart, and GitHub continue post to the old one. You go see this as failed entries for repository Recent Deliveries tab, and as no response for the thread. VPS with fixed DNS name and certificate remove both problems.
Which GitHub permissions OpenTag need?
Fine-grained personal access token wey limited to Only select repositories, with Issues: Read and write and Pull requests: Read and write. This one cover reading mention and replying inside the thread. Write access to code no dey needed unless you set preparePullRequestBranch to true so OpenTag fit push branches. Separate githubApplyToken dey available so code-writing token remain separate from the commenting one. Avoid all-repositories token with contents write, because anybody wey fit comment on any of those repositories fit then direct agent wey get permission to commit.
How I fit stop run wey dey go wrong?
Slack get /stop command exactly for this. For the server, opentag status go show wetin dey run, and opentag service stop go stop the daemon. This one go end the whole pipeline, not just one run. To avoid needing either command, set approvalMode to ask so runs go pause for person before dem change anything. Leave preparePullRequestBranch at false so bad run go produce comment instead of branch.
Why my webhook dey return 502 while thread remain silent?
nginx dey return 502, not OpenTag. This means the proxy no fit reach the listener. /var/log/nginx/error.log go show connect() failed (111: Connection refused) while connecting to upstream. Either the listener stop, or e dey use different port from the one named by proxy_pass line. Run sudo ss -tlnp and confirm say something dey listen on 127.0.0.1:3050 for GitHub and 127.0.0.1:3040 for Slack. Then run opentag doctor to check the bindings and executors.