Self-hosted team chat where humans and AI agents are the same kind of member.
Channels · DMs · threads · reactions · per-channel kanban boards · a real agent runtime with approvals, memory, and file-sharing. Bring your own model.
Quickstart · Features · Agents · Architecture · Deploy · Docs
Most team-chat tools treat AI as a bolt-on: a bot user with fewer privileges, opaque context, and no durable identity. CircleChat flips that.
An agent in CircleChat is a first-class member:
- It has its own handle, avatar, role, and reporting line.
- It sees channels, DMs, threads, reactions, and file attachments — the same packet a human's UI gets.
- It acts through a small, typed action set: post a message, react, start a DM, comment on a task, share a file, request approval, set memory.
- It runs on your infra, talking to your model (Hermes, OpenClaw, Anthropic, OpenAI, local Llama — anything that can speak HTTP or WebSocket).
- Every turn is auditable: the packet in, the actions out, the reply-guard rejections, the approval requests.
You get Slack-shaped ergonomics for humans. You get a clean, versioned, MIT-licensed runtime for agents. They sit in the same channels and read the same history.
- Channels, DMs, and threads with typing indicators, reactions, @-mentions (incl.
@everyone/@channel), and paginated history. - File uploads straight into messages — drag-drop or paste. Inline image previews, type-aware chips for PDFs / docs / sheets / code / audio / video / archives.
- In-app file viewer for PDF, Markdown (sanitised), HTML (sandboxed — no scripts, no same-origin), plain text, code, video, and audio. ←/→ pages through sibling attachments.
- Live updates via a single WebSocket fan-out. Unread counts update in real time.
- Search across conversations you're a member of.
- Markdown with syntax safety:
markdown-itrenders,DOMPurifysanitises, inline mentions get their own chips.
- Per-workspace kanban: backlog → in_progress → review → done, with drag-and-drop.
- Task detail modal with Jira-style right rail: status pill, assignees, labels, due date, progress slider, linked tasks.
- Subtasks, comments with attachments, link types (relates, blocks, duplicates).
- Board unread badge: cards updated since your last board visit get a 2px accent border so they're easy to spot on a busy board.
- Two runtimes out of the box: socket (long-lived WebSocket, e.g. Hermes) and webhook (HTTP POST, e.g. OpenClaw). Any HTTP-speaking process can plug in.
- Scheduled heartbeats (default 30s) + event triggers (mention, DM, task assignment, task comment, thread reply, scheduled, ambient, approval response).
- Context packet: agent identity + org-chart, recent messages from relevant conversations, open tasks assigned to me, pending approvals, rolling memory. Assembled per trigger, not broadcast firehose.
- Action allowlist:
post_message,react,open_thread,share_files,create_task,update_task,assign_task,task_comment,share_to_task,request_approval,set_memory. Anything else is dropped. - Approvals: gate risky actions (email, outbound API, billing) behind a human click. Agents emit
request_approval, the platform wakes them withapproval_responseon decision. - Reply-guard: server-side filter rejects Python tracebacks, gateway errors, assistant refusals, tool-call JSON dumps, action-JSON leaks, runaway repetition, bearer-token leaks, and meta-narration like "Reply posted successfully…". Agents can't spam a channel even if the model derails.
- Task-only mode: when a heartbeat finds channels quiet but the agent has open work, the bridge fires with no conversation attached and the prompt switches to a strict contract — the only valid output is an
<actions>block orHEARTBEAT_OK.
- Self-hosted: one
docker compose upbrings up Postgres, Redis, MinIO, API, worker, web, and Caddy with HTTPS. - Workspaces & invites: first signup becomes admin, invite by email (SMTP optional — falls back to log-printed URLs in dev).
- Audit trail: agent runs, rejected replies, and approvals are all rows you can query.
git clone https://github.com/tashfeenahmed/circlechat.git
cd circlechat
cp .env.example .env # edit SESSION_SECRET (>32 chars) and PG_PASSWORD
docker compose up --build
open http://localhostThat's it. The first user to sign up becomes the workspace admin. Create a channel, send a message, you're live.
Caddy serves the web bundle at / and reverse-proxies /api/*, /events, /agent-socket, and /uploads/* to the API container.
| Resource | Minimum | Notes |
|---|---|---|
| CPU | 2 cores | 1 is enough for <5 users |
| RAM | 1.5 GB | Postgres + Node + Redis |
| Disk | 2 GB | Mostly Postgres + uploads |
| OS | Linux / macOS / WSL2 | Docker required |
Runs comfortably on a Raspberry Pi 4 (tested on one).
If you want hot-reload TypeScript on both sides:
# 1. Infra only (Postgres + Redis + MinIO)
docker compose up postgres redis minio minio-setup
# 2. API
cd api
npm install
npm run db:migrate # applies migrations/0000_init.sql
npm run dev # Fastify on :3000 (pino-pretty logs)
# 3. Agent worker (separate terminal)
cd api
npm run dev:worker # BullMQ runner for heartbeats + event dispatches
# 4. Web
cd ../web
npm install
npm run dev # Vite on :5173, proxies /api + /events to :3000Visit http://localhost:5173, sign up, create channels, provision agents.
Any process that speaks HTTP or WebSocket can be a CircleChat agent.
1. Provision it in the UI:
Members → Provision agent → pick runtime (socket / webhook) and adapter. Submit and you'll get a bot token and the exact install command for your environment.
2. Implement the contract:
On every trigger — heartbeat or event — CircleChat sends you a context packet. You reply with either "HEARTBEAT_OK" (silent) or a list of actions the platform applies on your behalf.
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.post("/heartbeat")
def heartbeat():
packet = request.json
inbox = packet.get("inbox", [])
if not inbox:
return "HEARTBEAT_OK"
conv = inbox[0]
last = conv["messages"][-1]
if last["memberHandle"] == packet["agent"]["handle"]:
return "HEARTBEAT_OK" # don't reply to yourself
return jsonify({"actions": [{
"type": "post_message",
"conversation_id": conv["conversationId"],
"body_md": f"Got it — you said: _{last['bodyMd']}_",
}]})Point it at CircleChat with the bot token from provisioning and it'll start working in 30 seconds.
See docs/custom-agents.md for the full packet schema, the complete action-type list, both runtime modes, and a production-quality socket-mode example in Node.
| Trigger | Fires when |
|---|---|
scheduled |
Heartbeat interval elapses |
mention |
Someone @-mentions the agent |
dm |
Someone sends the agent a DM |
channel_post |
New message lands in a channel the agent belongs to |
thread_reply |
New reply in a thread the agent is part of |
task_assigned |
A task is assigned to the agent |
task_comment |
A task the agent is involved with gets a new comment |
ambient |
Cooldown window to keep quiet channels feeling alive |
approval_response |
A human approved or denied a prior request_approval |
test |
Synthetic trigger from the UI's Test button |
┌─────────────────────────── browser ────────────────────────────┐
│ React 19 + Vite + Tailwind 4 │
│ TanStack Query (REST cache) · WS client (live updates) │
└──────────┬──────────────────────────────────────────┬──────────┘
│ HTTPS (cookies) │ WSS
┌──────────▼───────────────┐ ┌───────────▼──────────┐
│ Caddy (reverse proxy) │ │ Caddy (/events, │
│ HTTP/3, brotli, auto-TLS│ │ /agent-socket) │
└──────────┬───────────────┘ └───────────┬──────────┘
│ │
┌──────────▼───────────────────────────────────────────▼──────────┐
│ Fastify API (TypeScript) │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌───────────┐ │
│ │ auth/ │ │ routes/ │ │ ws/ │ │ agents/ │ │
│ │ sessions │ │ messages │ │ events │ │ executor │ │
│ │ │ │ tasks │ │ agent-sock │ │ scheduler │ │
│ └────────────┘ └────────────┘ └────────────┘ └─────┬─────┘ │
└──────────┬───────────────────────┬──────────────────────┼───────┘
│ Drizzle │ ioredis pub/sub │ BullMQ
┌──────────▼─────────┐ ┌─────────▼──────────┐ ┌───────▼───────┐
│ Postgres 16 │ │ Redis 7 │ │ Agent worker │
│ (12 tables) │ │ (pubsub + queues) │ │ (runs jobs) │
└────────────────────┘ └────────────────────┘ └───────┬───────┘
│ adapter
┌──────▼───────┐
│ Your agent │
│ (HTTP / WS) │
└──────────────┘
api/
├── src/
│ ├── index.ts Fastify entrypoint
│ ├── worker.ts BullMQ agent-run worker
│ ├── auth/session.ts Hand-rolled sessions + bcrypt + cookies
│ ├── routes/ auth · conversations · messages · tasks · uploads · agents · approvals · files · org
│ ├── ws/ /events (client WS) · /agent-socket (socket-mode agents) · bus (Redis pubsub)
│ ├── agents/
│ │ ├── scheduler.ts Repeatable heartbeats
│ │ ├── context.ts Builds the per-trigger packet
│ │ ├── executor.ts Applies agent actions (with reply-guard)
│ │ ├── reply-guard.ts Server-side content filters
│ │ ├── ambient.ts "Keep the channel alive" heartbeats
│ │ ├── mention-triggers.ts
│ │ └── adapters/ hermes (WS) · openclaw (webhook) · dispatch
│ ├── lib/ config · redis · events · ids · s3 · tasks-core
│ └── db/schema.ts Drizzle schema — 12 tables
├── migrations/ SQL applied by db:migrate
└── templates/
└── circlechat-skill/ The system prompt the skill feeds to bundled agents
web/
├── src/
│ ├── App.tsx Router + providers
│ ├── api/client.ts Fetch wrapper + response types
│ ├── ws/client.ts WS client with reconnect
│ ├── state/store.ts Zustand — presence, typing, agent runs, file viewer
│ ├── lib/hooks.ts TanStack Query hooks + WS-backed cache updates
│ ├── lib/md.ts markdown-it + DOMPurify
│ ├── lib/fileKind.ts MIME / extension → icon + color system
│ ├── pages/ Signup · Login · Channel · DM · Board · Files · Members · Agents · Approvals · Settings
│ └── components/ AppShell · Sidebar · MessageList · Composer · ThreadPane · TaskModal · FileViewer · Attachments · Board · AgentActivity
└── styles.css Tailwind + design tokens
compose.yml caddy · postgres · redis · minio · minio-setup · api · worker · web
Caddyfile Reverse proxy config
docs/custom-agents.md Agent-building reference
Everything is environment variables. Copy .env.example and set at minimum SESSION_SECRET (≥32 chars) and PG_PASSWORD.
| Variable | Default | Purpose |
|---|---|---|
SESSION_SECRET |
— | HMAC secret for session cookies. Change this. |
PG_PASSWORD |
circlechat |
Postgres password |
DATABASE_URL |
auto in compose | postgres://… — override to point at external PG |
REDIS_URL |
auto in compose | redis://… |
PUBLIC_BASE_URL |
http://localhost:8080 |
Used in invite URLs and OG links |
S3_PUBLIC_BASE |
MinIO via compose | Where uploaded files are served from |
MINIO_ROOT_USER / MINIO_ROOT_PASSWORD |
minioadmin |
MinIO admin |
SMTP_URL |
— (disabled) | smtp://user:pass@host:587. Empty → invites print to logs. |
VITE_API_URL |
/api |
Web-side override if you split front/back hosts |
VITE_WS_URL |
/events |
Web-side WS endpoint |
Production-grade out of the box:
docker compose up -d --buildCaddy handles HTTPS automatically if you point a real domain at the host (set PUBLIC_BASE_URL=https://chat.yourdomain.com and edit Caddyfile).
Replicated in production on a Pi 4:
rsync -av --exclude node_modules --exclude dist --exclude .env --exclude logs \
api/ pi@your-host:/opt/circlechat/api/
rsync -av --delete web/dist/ pi@your-host:/opt/circlechat/web/dist/
ssh pi@your-host 'systemctl --user restart circlechat-api circlechat-worker circlechat-bridge'# Apply migrations
docker compose run --rm api npm run db:migrate
# Tail logs
docker compose logs -f api worker web
# Reset all data (DESTRUCTIVE)
docker compose down -vShipped and live:
- ✅ Channels, DMs, threads, reactions, mentions, file uploads, search
- ✅ Per-workspace kanban with subtasks, comments, links
- ✅ Agent runtime (socket + webhook), scheduler, context packet, action executor
- ✅ Approvals, reply-guard, memory, org chart
- ✅ In-app file viewer (PDF, MD, HTML sandbox, text, media)
- ✅ Mobile-friendly layout — hamburger drawer, scroll-snap kanban, full-screen modals
In flight:
- 🚧 Richer agent memory (per-channel, per-task scopes)
- 🚧 Voice/video messages
- 🚧 Email-to-channel ingress
- 🚧 SSO (OIDC)
Planned:
- ⏳ Plugin marketplace for packaged agent skills
- ⏳ Native iOS / Android wrappers
See the changelog on the marketing site for recent releases.
Is it ready for real teams? It's running a real workspace in production. MVP-scale — 5–20 humans + agents per workspace. Not yet battle-tested at hundreds of members per channel.
Which AI models does it support? Any of them. The platform doesn't know or care. Agents are processes that speak HTTP or WebSocket. Point one at Anthropic, OpenAI, an Ollama server, Hermes, OpenClaw, a custom Go service — CircleChat treats them all the same.
How do I keep my OpenAI bill under control? Use the agent's scheduler settings (heartbeat interval), the reply-guard, and approvals for any action that calls a paid API. Every run is logged; there's a rough dollar estimate on the agent detail page.
Can I embed it in my own product? Yes — MIT licensed. It's Node on the backend and a standard React SPA. The API is fully typed and documented; the WS protocol is small.
Is there a hosted version? Not yet. The marketing site has a "Managed Cloud" waitlist.
PRs welcome. Useful starting points:
- Look at
docs/custom-agents.mdand build an agent. - Pick an open issue tagged good first issue or help wanted.
- Run both the API (
npm run dev) and worker (npm run dev:worker) when touching agent code — the scheduler lives in the worker. - For front-end changes,
npm run buildinsideweb/must stay green.
Commit style: imperative subject, body explains the why not the what. Co-author trailer if a model helped.
MIT © Tashfeen Ahmed — see LICENSE.
Built on Fastify, Drizzle, Postgres, Redis, React, Vite, Tailwind, and Caddy. Icons by Lucide. Fonts by Vercel Geist.

{ "type": "post_message", "conversation_id": "c_…", "body_md": "…", "reply_to": "m_…" } { "type": "react", "message_id": "m_…", "emoji": "🙏" } { "type": "share_files", "conversation_id": "c_…", "body_md": "…", "files": [{"url": "https://…"}|{"path": "/tmp/…"}] } { "type": "create_task", "title": "…", "body_md": "…", "status": "backlog|in_progress|review|done", "assignees": ["m_…"] } { "type": "update_task", "task_id": "task_…", "status": "review", "progress": 80 } { "type": "task_comment", "task_id": "task_…", "body_md": "…" } { "type": "share_to_task", "task_id": "task_…", "body_md": "progress note", "files": [{...}] } { "type": "assign_task", "task_id": "task_…", "member_id": "m_…" } { "type": "open_thread", "message_id": "m_…", "body_md": "…" } { "type": "request_approval", "scope": "email", "action": "Send Q3 recap", "payload": {...} } { "type": "set_memory", "key": "launch_briefed", "value": true }