An event-driven AI team member that monitors Slack, GitHub, Linear, and PostHog, then takes action through OpenCode sessions with policy-enforced tool access.
┌─────────┐
│ ingress │
│ (nginx) │
└────┬────┘
┌──────────┬───┴────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌───────────┐
│ gateway │ │ mission │ │ opencode │
│ webhooks │ │ control │ │ AI engine │
└────┬─────┘ └────┬─────┘ └──┬─────┬──┘
│ │ MCP │ │ CLI
│ ┌────┘ ▼ ▼
▼ ▼ ┌──────────┐ ┌──────────────┐
┌─────────────────┐ │ proxy │ │ remote-cli │
│ runner │ │ policy │ │ git/gh CLI │
│ sessions │ └────┬─────┘ └──────────────┘
└─────────────────┘ │
┌──────────┼──────────┬──────────┐
▼ ▼ ▼ ▼
Linear PostHog Slack Grafana
(hosted) (hosted) MCP MCP
Gateway receives events and triggers the runner. OpenCode connects to proxy instances for tool access and uses remote-cli for Git/GitHub CLI operations.
| Service | Port | Package | Role |
|---|---|---|---|
| cron | — | docker/cron |
BusyBox crond for scheduled hey-thor prompts |
| mc | 3100 | Docker image | Mission Control task board and scheduler (UI + API) |
| mc-bridge | — | @thor/mc-bridge |
Polls Mission Control task queue, dispatches to runner |
| data | 3080 | docker/data |
Nginx credential proxy for internal APIs (requires custom config) |
| gateway | 3002 | @thor/gateway |
Slack & GitHub webhook ingestion, event batching, trigger orchestration |
| remote-cli | 3004 | @thor/remote-cli |
Git/GitHub CLI proxy with PAT credential isolation |
| grafana-mcp | 8000 | Docker image | Grafana MCP server for Loki/Tempo queries |
| ingress | 8080 | docker/ingress |
Nginx reverse proxy with Vouch SSO |
| opencode | 4096 | Docker image | AI agent runtime (headless server) |
| proxy | 3010–3013 | @thor/proxy |
MCP tool allow-listing, credential injection, audit logging |
| runner | 3000 | @thor/runner |
OpenCode session management, prompt execution, NDJSON progress streaming |
| slack-mcp | 3003 | @thor/slack-mcp |
Slack API MCP server, progress message lifecycle |
| vouch | 9090 | Docker image | OAuth/SSO authentication proxy (Vouch Proxy) |
- Events arrive — Slack mentions, GitHub webhooks, cron schedules, and Mission Control tasks hit the gateway/bridge
- Smart batching — Events are queued per correlation key (e.g., Slack thread) with configurable delays (3s for direct mentions, 60s for unaddressed messages and GitHub events, immediate for cron)
- Session continuity — The runner maps correlation keys to persistent OpenCode sessions, resuming context across interactions
- Policy-enforced tools — OpenCode accesses integrations through proxy instances that enforce allow-lists and log every tool call
- Progress visibility — Tool activity streams back to Slack as live-updating progress messages that auto-clean when the bot replies
- Docker & Docker Compose
- pnpm 9.x (for local development)
- Node.js 22+
# Set required environment variables
export GITHUB_PAT=github_pat_...
export GRAFANA_SERVICE_ACCOUNT_TOKEN=glsa_...
export GRAFANA_URL=https://your-instance.grafana.net
export LINEAR_API_KEY=lin_api_...
export POSTHOG_API_KEY=phx_...
export SLACK_BOT_TOKEN=xoxb-...
export SLACK_BOT_USER_ID=U...
export SLACK_SIGNING_SECRET=...
export VOUCH_DOMAINS=example.com
export VOUCH_GOOGLE_CLIENT_ID=...
export VOUCH_GOOGLE_CLIENT_SECRET=...
export VOUCH_JWT_SECRET=...
export VOUCH_WHITELIST=alice@example.com,bob@example.com
# Start all services
docker compose up --build -d
# Verify health
curl http://localhost:8080/health
# View logs
docker compose logs -f
# Stop
docker compose downThor ships with generic defaults. A new deployment needs the following configuration:
Copy .env.example to .env and fill in:
| Variable | Required | Service | Purpose |
|---|---|---|---|
CRON_SECRET |
Yes | gateway, cron | Shared secret for cron endpoint auth |
DATA_ROUTES |
No | data | Comma-separated list of data proxy routes (see below) |
GIT_USER_EMAIL |
No | remote-cli | Git author email (default: thor@localhost) |
GIT_USER_NAME |
No | remote-cli | Git author name (default: thor) |
GITHUB_PAT |
Yes | remote-cli | GitHub fine-grained PAT |
GRAFANA_SERVICE_ACCOUNT_TOKEN |
Yes | grafana-mcp | Grafana service account token |
GRAFANA_URL |
Yes | grafana-mcp | Grafana instance URL |
INGRESS_PORT |
No | ingress | Host port (default: 8080) |
MC_API_KEY |
No | mc-bridge | Mission Control API key for agent auth |
MC_POLL_INTERVAL_MS |
No | mc-bridge | Task queue poll interval (default: 10000) |
LINEAR_API_KEY |
Yes | proxy | Linear API access |
OPENCODE_CPU_LIMIT |
No | opencode | CPU limit for OpenCode container (default: 3) |
OPENCODE_MEMORY_LIMIT |
No | opencode | Memory limit for OpenCode container (default: 4g) |
OPENCODE_URL |
No | runner | OpenCode server URL (default: http://opencode:4096) |
POSTHOG_API_KEY |
Yes | proxy | PostHog API access |
SESSION_CWD |
No | runner | Working directory for new sessions (default: /workspace) |
SLACK_ALLOWED_CHANNEL_IDS |
No | gateway, slack-mcp | Comma-separated channel IDs to restrict the bot to |
SLACK_BOT_TOKEN |
Yes | slack-mcp | Slack app bot token (xoxb-...) |
SLACK_BOT_USER_ID |
Yes | gateway | Bot's Slack user ID — used to ignore own messages |
SLACK_SIGNING_SECRET |
Yes | gateway | Webhook signature verification |
SLACK_TIMESTAMP_TOLERANCE_SECONDS |
No | gateway | Signature timestamp tolerance (default: 300) |
VOUCH_CALLBACK_URL |
No | vouch | OAuth callback URL (default: http://localhost:8080/vouch/auth) |
VOUCH_COOKIE_DOMAIN |
No | vouch | Cookie domain (default: localhost) |
VOUCH_DOMAINS |
Yes | vouch | Allowed domain for Vouch login (e.g., example.com) |
VOUCH_GOOGLE_CLIENT_ID |
Yes | vouch | Google OAuth client ID |
VOUCH_GOOGLE_CLIENT_SECRET |
Yes | vouch | Google OAuth client secret |
VOUCH_JWT_SECRET |
Yes | vouch | Session JWT signing secret |
VOUCH_WHITELIST |
Yes | vouch | Comma-separated email allowlist for Vouch login |
If you have internal APIs that Thor should access with injected credentials, add routes to .env:
DATA_ROUTES=billing,analytics
DATA_ROUTE_billing_UPSTREAM=https://billing.example.com/
DATA_ROUTE_billing_KEY=sk-your-api-key
DATA_ROUTE_billing_HEADER=X-Custom-Auth # optional, defaults to X-API-Key
DATA_ROUTE_analytics_UPSTREAM=https://analytics.example.com/
DATA_ROUTE_analytics_KEY=sk-your-other-keyThe data container generates its nginx config from these vars at startup. When DATA_ROUTES is empty, it proxies to httpbin.org as a no-op fallback. See docker/data/default.conf.template.example for the equivalent static config.
The bundled agent prompt (docker/opencode/agents/build.md) contains only generic behavior rules — no team-specific context. After starting Thor, open the OpenCode web UI and tell Thor about your team in conversation. Ask it to remember key facts — Thor writes them to its persistent memory directory automatically. Things to tell it:
- Your team name, Slack bot ID, and key channel IDs
- Team members — names, Slack IDs, GitHub usernames, and roles
- Which repos are mounted, default branches, CI conventions
- If using the data proxy, the available routes and their API schemas
Exec into the remote-cli container to clone repos — this runs as the thor user with the correct PAT credentials, avoiding permission issues:
docker compose exec remote-cli git clone https://github.com/your-org/your-repo.git /workspace/repos/your-repoRepos in /workspace/repos/ are mounted read-only into OpenCode. Thor creates worktrees under /workspace/worktrees/ for code changes.
Copy docs/notify-thor.example.yml to .github/workflows/notify-thor.yml in any source repository you want Thor to monitor. Add THOR_GATEWAY_URL as a repository variable pointing to the gateway endpoint.
Add scheduled prompts to docker-volumes/workspace/cron/crontab. Each line triggers Thor with a prompt on a schedule. See docs/plan/2026031204_cron-triggers.md for examples.
# Install dependencies
pnpm install
# Build all packages
pnpm build
# Run all services in dev mode (watch)
pnpm dev
# Run tests
pnpm test
# Run tests in watch mode
pnpm test:watch
# Type checking
pnpm typecheck
# Format code
pnpm formatthor/
├── packages/
│ ├── common/ # Shared: logging (pino), Zod schemas, worklog utilities
│ ├── gateway/ # Webhook ingestion, event queue, trigger orchestration
│ ├── runner/ # OpenCode session management, progress streaming
│ ├── proxy/ # MCP policy proxy (one instance per integration)
│ ├── slack-mcp/ # Slack MCP server + progress message manager
│ └── remote-cli/ # Git/GitHub CLI proxy with credential isolation
├── docker/
│ ├── opencode/ # OpenCode container image
│ ├── ingress/ # Nginx ingress config
│ ├── cron/ # BusyBox crond for scheduled prompts
│ └── data/ # Internal API credential proxy
├── docs/
│ ├── feat/ # Feature specs and architecture
│ └── plan/ # Implementation plans (chronological)
├── scripts/ # Test and utility scripts
├── docker-compose.yml
├── Dockerfile # Multi-stage build for all Node.js services
└── AGENTS.md # AI agent workflow instructions
Each integration has a policy config file (e.g., proxy.linear.json):
{
"upstream": {
"url": "https://mcp.linear.app/mcp",
"headers": {
"Authorization": "Bearer ${LINEAR_API_KEY}"
}
},
"allow": ["get_issue", "list_issues", "list_teams"]
}The allow list uses exact tool names. Environment variables in headers are interpolated at startup. Unmatched tools are blocked, and all decisions are audit-logged.
| Port | Config | Upstream |
|---|---|---|
| 3010 | proxy.linear.json |
Linear hosted MCP |
| 3011 | proxy.posthog.json |
PostHog hosted MCP |
| 3012 | proxy.slack.json |
slack-mcp:3003 |
| 3013 | proxy.grafana.json |
grafana-mcp:8000 |
Environment variables are documented in the Deployment Configuration section above.
Thor runs an AI agent with access to external APIs, so security is enforced in layers — no single component is trusted in isolation.
Each service holds only the credentials it needs. OpenCode has no direct access to any API token.
- Proxy — Injects API keys into upstream MCP requests via config-time
${ENV_VAR}interpolation. Credentials never reach OpenCode. - remote-cli — Injects
GITHUB_PATat execution time viaGIT_ASKPASS(a temporary script). The PAT is never passed as a CLI argument or environment variable visible to the git process. - data — Nginx sidecar that injects API keys into proxied requests. Routes are configured via
DATA_ROUTESenv vars in.env(see.env.example). The entrypoint generates the nginx config at startup — no manual template editing needed. Falls back to httpbin.org when no routes are set. Trade-off: the data container receives the full.envviaenv_fileso that admins can add new proxy targets without editingdocker-compose.yml. This means all env vars (including unrelated secrets likeSLACK_BOT_TOKEN) are visible inside the container. This is acceptable because the data container runs stock nginx, which does not expose environment variables to proxied requests or logs. If stricter isolation is needed, use a dedicateddata.envfile instead. - slack-mcp — Holds
SLACK_BOT_TOKENexclusively; no other service touches Slack's API directly.
The proxy sits between OpenCode and every upstream MCP server. Each proxy instance loads an allow-list of exact tool names from its config file.
- Tools not in the allow-list are never listed to OpenCode and never executed
- Blocked calls return an error:
"Unknown tool: <name>" - Policy drift detection at startup — if an allow-list entry doesn't match any upstream tool, the proxy warns (dev) or refuses to start (production)
- remote-cli blocks
cloneandinitcommands server-side — Thor can only work with repos that an admin has explicitly cloned into/workspace/repos/. This prevents the agent from fetching arbitrary repositories that could contain malicious instructions or prompt injection in READMEs, issue templates, or commit messages
- Slack — HMAC-SHA256 signature verification using
crypto.timingSafeEqualwith configurable timestamp tolerance (default 300s) - GitHub — Events are delivered via GitHub Actions workflow (
notify-thor.example.yml), not direct webhooks, so payloads arrive from a trusted CI context
- Vouch Proxy — Google OAuth SSO in front of OpenCode's web UI
- Nginx ingress —
auth_requestdirective validates sessions via Vouch; unauthenticated users are redirected to login - Unprotected paths — Only
/slack/*and/github/*(webhook endpoints with their own auth) and static assets bypass SSO
All custom-built containers run as a dedicated thor user (uid/gid 1001) instead of root. This limits the blast radius if a container is compromised — the process cannot modify system files, install packages, or escalate privileges. The only exception is the cron container, which requires root for crond.
All internal services bind to 127.0.0.1 in Docker Compose. Only the ingress proxy (port 8080) is exposed to the network. Inter-service communication happens over Docker's internal network.
OpenCode's container mounts are scoped:
| Mount | Access | Purpose |
|---|---|---|
/workspace/cron |
read-write | Crontab for scheduled jobs |
/workspace/memory |
read-write | Persistent agent memory |
/workspace/repos |
read-only | Source code — cannot be modified directly |
/workspace/worklog |
read-only | Audit logs — cannot be tampered with |
/workspace/worktrees |
read-write | Git worktrees for changes |
Every proxy tool call is logged to day-partitioned JSON files under /workspace/worklog/:
worklog/2026-03-12/json/1710244800000_tool-call_list-issues.json
Each record includes: tool name, decision (allowed/blocked), arguments (truncated to 4KB), result (truncated to 4KB), duration, and any error. All services also emit structured JSON logs via pino.
Zod schemas validate requests at every service boundary:
- Gateway validates Slack event envelopes and GitHub payloads before processing
- Runner validates trigger requests (
prompt,correlationKey,sessionId) - slack-mcp enforces upper bounds on thread reads (200 replies), channel history (100 messages), and file downloads (20MB)
- Progress events from the runner are validated against a discriminated union schema before forwarding
pnpm test # Unit tests (vitest)
pnpm test:proxy # Integration: proxy → upstream MCP
pnpm test:e2e # End-to-end via Docker Compose