A headless AI CEO agent: it watches the business (Stripe, Mixpanel, ClickUp), answers the team, runs PM checks and heartbeats, and pushes toward revenue — discussing before acting, with the founder holding the final word.
The agent's persona — name, voice, account, and operating rules — is defined entirely by CONTRACT.md plus the AGENT_NAME setting, not by the code. This instance runs as Aso Dara for Subturtle; another deployment loads a different contract and name and is otherwise the same body. The product spec and milestones are in prd.md.
The agent has no long-running "brain". Each time it needs to act, the app spawns one fresh headless Claude Code run (claude -p) with CONTRACT.md loaded as the system prompt:
claude --print "<task>" --append-system-prompt-file CONTRACT.md \
--output-format json --model sonnet --disallowedTools <guardrails> ...
Because the contract is re-read on every run, editing it takes effect on the next wake — no redeploy. The run inherits the claude.ai connectors (ClickUp authed as the agent's account, Stripe/Mixpanel read), so the agent reads and writes the business as itself. This wrapper is src/agent/runner.ts (runAgent) — the single engine every trigger goes through.
A long-running Node process (pm2) turns external events into agent runs. Two transports feed one categorizer:
ClickUp task event ─POST─▶ Cloudflare ─▶ tunnel ─▶ HTTP listener (:8787)
│ verify X-Signature (HMAC-SHA256) → 200 ack ──────────────┐ (fast, synchronous)
└─ off the request path ▶ categorize ─┬─ C drop (incl. loop guard)
├─ B append data/events/inbox.jsonl
└─ A handleWake(thread, msg) ─▶ runAgent
chat (DMs/@mentions) ─poll ~90s via REST─▶ new, not self ▶ handleWake(...) ─▶ runAgent
- Webhook listener (
src/server/) receives ClickUp task events, verifies theX-Signature(HMAC-SHA256 of the raw body) against the registered secret, acks 200 immediately, then dispatches off the request path so the ack stays fast. The webhook is registered workspace-wide (no list scope), so the agent sees activity in every list it works across — not just one — and any new list is covered automatically. - Chat poller (
src/poller/) covers what webhooks can't: ClickUp has no chat webhook, so DMs and channel @mentions are polled every ~90s via the v3 chat REST API. Per-channel cursors (data/poller/cursors.json) mean only genuinely new messages wake the agent; on first sight a channel is baselined so old history never replays.
src/dispatch/categorize.ts sorts every event:
| What | Handling | |
|---|---|---|
| A | A task comment that @mentions the agent or continues a thread it's already in, or any DM | Wake now: load the thread, reply in that thread, record |
| B | Other task activity (created / updated / status / moved …) | Append to data/events/inbox.jsonl for the PM check |
| C | Anything authored by the agent itself (loop guard), or irrelevant types | Drop |
The loop guard keys on the agent's own ClickUp user id (AGENT_USER_ID); @mention detection keys on AGENT_NAME — both config, so nothing about the identity is baked into the code.
To pick the message to answer, the categorizer flattens root comments and their threaded replies (newest first) and takes the latest one from anyone but the agent. That message is Category A if it @mentions the agent or is a follow-up reply in a thread the agent already participates in — so once a back-and-forth is going, you don't have to re-@mention it each turn. A brand-new top-level comment with no mention stays Category B. The reply is always threaded under the root of wherever that message lives, and dedup keys on the real comment/reply id, so an answer lands in the right place exactly once.
The app owns the agent's episodic memory as plain files so it survives restarts and is debuggable:
data/threads/<source>-<id>.md— one file per conversation (e.g.clickup-task-86e….md,chat-8crzyb7-1458.md), holding the running history. On a wake, the matching file is loaded as context so the agent answers with the full story.data/threads/INDEX.md— one line per thread.- Files are compacted (older part summarized to a "story so far") once they pass a size limit.
- Task replies post as a threaded reply under the triggering comment (not a new root comment), opening with a real
@mentionof the asker so they get a ClickUp notification. ClickUp has no MCP tool for threaded replies, so the agent composes the text and the app posts it via REST (/comment/{id}/reply). A root-level comment is used only if the asker explicitly asks ("root", "top level", "new comment"). - Markdown actually renders. ClickUp ignores markdown in plain
comment_text(it shows## H/**bold**/@nameliterally), so the app converts the agent's markdown into ClickUp's rich comment segment array (src/clickup/markdown.ts→richBodyinsrc/clickup/rest.ts): headings, bold/italic/code, links, and lists format, and a leadingtagsegment becomes a real mention chip. Both reply paths use it — interactive replies and the proactive comments the PM check posts (see below) — so formatting is identical everywhere. The connector's plaincomment_textis only a fallback if the rich form is ever rejected. - Chat replies go out through the agent's connector, addressing the person and adding them as a follower so they're notified.
Beyond reacting, the agent runs two scheduled routines (src/rhythms/), via a 60s scheduler that
persists state in data/schedule.json (survives restarts, catches up a missed run, never
double-fires):
- PM check — every 5h (Sonnet): drains the inbox, groups activity by task, chases stuck work,
answers, encourages, and proposes the next batch if nothing is active. Archives the inbox after.
Its comment tool is blocked too: it emits the comments it wants as a small directive and the
app posts each through the same rich path as a reply (markdown + real
@mention), so proactive comments format correctly instead of going out as literalcomment_text. - Heartbeat — weekdays 08:00 (
HEARTBEAT_TZ) (Opus): clones the council repo (subturtle-docs) read-only for context (playbook, metrics framework, decisions), pulls Stripe/Mixpanel/ClickUp/repo state, posts 2–4 ranked moves to the public channel, and writes a beat log todata/heartbeats/<date>.md(the agent's own history, committed to this repo — not the council repo).
Both can be run on demand: npm run trigger -- pm-check|heartbeat, or Navid DMs the agent
"run heartbeat" / "run pm check" (honored only from his account). The agent's history (threads +
beat logs) is committed to the repo at the end of each rhythm; secrets in data/ stay git-ignored.
Beyond the two fixed rhythms, the agent can create its own recurring jobs — e.g. "every Monday
09:00, top up the content backlog and flag what's due." It does this from chat by emitting a
schedule action in its reply ({type:"schedule", title, cron, task, tz?, id?}, with unschedule
to remove one); the job is persisted to data/schedules.json and run by the same 60s scheduler
(src/rhythms/schedules.ts).
- Cron, in the job's timezone. Standard 5-field cron (
m h dom mon dow), matched by a small dependency-free parser (src/rhythms/cron.ts). The minute field can't be*(or a*-step), so a job can't run more often than ~hourly; up to 25 jobs. - Unattended, like the other rhythms. Each run is a fresh
claude -pwith the browser disallowed — so a job does ClickUp/repo/analysis work (drafting tasks, chasing, summarising), not posting to external sites like LinkedIn (that needs the attended browser). - Restart-safe. Jobs survive restarts (persisted), never double-fire (the run is stamped before it executes), and catch up a missed run: if the box was down across a job's minute, the next tick still fires it — once — as long as the miss was within the last 6h (older misses are dropped, and a new job never retro-fires for occurrences before it existed).
The agent manages these from chat (its current jobs are shown to it each run, so it updates by id instead of duplicating). Task-thread replies compose text only, so schedules are set/edited via DM.
The contract is the primary control, backed at the tool layer (src/agent/policy.ts, passed as --disallowedTools): Stripe writes and ClickUp task-deletes are blocked on every run. During a task wake and the PM check the comment tool is also blocked — the agent composes (a reply, or a directive of comments) but cannot post, so the write-capable REST token stays server-side, the guardrails can't be bypassed, and every comment goes out through the rich-formatting path. Scheduled jobs and the heartbeat run unattended with the browser blocked, so a timer never drives the local Chrome.
How to stand up a live instance (this is the actual production setup — a Cloudflare Tunnel, because the host blocks direct inbound).
- Node 22+, npm,
git— and a logged-in Claude Code on the same machine/user (the agent runs inherit its connectors). - A ClickUp personal API token (
pk_…) for the agent's account — Settings → Apps → API Token. Used only for webhook register/list and the chat poll; never for acting. - A domain on Cloudflare (here:
subturtle.app) for the public webhook hostname.
npm install
cp .env.example .env # then edit .envSet the agent's persona and the required keys in .env (full list in Configuration):
AGENT_NAME=Aso Dara # this instance's name (used for @mention detection)
AGENT_USER_ID=113552267 # the agent's own ClickUp account id (loop guard)
CLICKUP_API_TOKEN=pk_xxxxxxxx
WEBHOOK_PUBLIC_URL=https://aso-agent.subturtle.app/clickup/webhook
SERVER_TLS=false # behind a tunnel; the listener serves plain HTTP on loopback
PORT=8787The agent's voice and rules live in CONTRACT.md — edit it to match the persona named above.
A tunnel dials out to Cloudflare, so it needs no inbound firewall/security-group rule (works behind managed/NAT'd hosts). Install cloudflared, then:
cloudflared tunnel login # browser auth; pick the zone (e.g. subturtle.app)
cloudflared tunnel create ceo-agent # note the tunnel UUID
# ~/.cloudflared/config.yml
# tunnel: <UUID>
# credentials-file: /home/<user>/.cloudflared/<UUID>.json
# ingress:
# - hostname: aso-agent.subturtle.app
# service: http://localhost:8787
# - service: http_status:404
cloudflared tunnel route dns --overwrite-dns ceo-agent aso-agent.subturtle.appAlternative: direct-to-origin (no tunnel)
If the host accepts public inbound, set SERVER_TLS=true, PORT=443, point a proxied Cloudflare A record at the box, set SSL/TLS mode Full, open the security group on 443, and:
mkdir -p data/tls
openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
-keyout data/tls/origin.key -out data/tls/origin.crt -subj "/CN=<your-hostname>"
sudo setcap 'cap_net_bind_service=+ep' "$(readlink -f "$(which node)")" # bind :443 as non-rootnpm run build
npm run webhook -- register # creates the workspace-wide ClickUp webhook, saves the signing secret to data/webhooks.json
npm run webhook -- list # show ClickUp's webhooks and reconcile with local statepm2 start ecosystem.config.cjs # starts ceo-agent (listener + poller)
pm2 start cloudflared --name ceo-tunnel -- tunnel --config ~/.cloudflared/config.yml run
pm2 save # persist the process list
sudo env PATH=$PATH pm2 startup systemd -u $USER --hp $HOME # relaunch on rebootcurl https://aso-agent.subturtle.app/health # → ok (proves ClickUp → tunnel → listener)Then, from a different account (not the agent's): post a comment @mentioning the agent on a task (expect a threaded reply + notification within seconds), and send it a DM (expect a chat reply within ~POLL_INTERVAL_MS).
pm2 status # both apps online?
pm2 logs ceo-agent # wake / dispatch / poller activity
pm2 logs ceo-tunnel # tunnel connections
pm2 restart ceo-agent --update-env # after an .env change
npm run webhook -- list # reconcile the webhook against ClickUp (re-register if missing)To deploy a code change: git pull && npm run build && pm2 restart ceo-agent. Contract-only changes (CONTRACT.md) need no rebuild or restart — the next wake reads the new contract.
Run any of these on demand; each is one headless run as the agent:
npm run trigger -- read # summarise the Subturtle.app board (no writes)
npm run trigger -- dm-navid # send the founder a first private chat message
npm run trigger -- comment <taskId> # read a task and post one comment
npm run trigger -- pm-check # run the PM check now (drain inbox, sweep work)
npm run trigger -- heartbeat # run the heartbeat now (assess, draft, write a beat log).env (copy from .env.example). Secrets stay here and in data/ — both git-ignored.
| Var | Meaning |
|---|---|
AGENT_NAME |
The persona this instance runs as (used for @mention detection) |
AGENT_USER_ID |
The agent's own ClickUp account id (loop guard) |
CLICKUP_API_TOKEN |
The agent's pk_… token — webhook register/list + chat poll only |
WEBHOOK_PUBLIC_URL |
Public URL ClickUp posts to (the tunnel hostname + /clickup/webhook) |
WEBHOOK_PATH |
Path the listener serves (default /clickup/webhook) |
SERVER_TLS |
false behind a tunnel (plain HTTP); true for direct-to-origin HTTPS |
PORT |
Listener port (8787 behind a tunnel; 443 direct) |
POLL_INTERVAL_MS |
Chat poll cadence (default 90000) |
THREAD_COMPACT_BYTES |
Compact a thread file past this size (default 24000) |
PM_CHECK_INTERVAL_MS |
PM check cadence (default 18000000 = 5h) |
HEARTBEAT_HOUR / HEARTBEAT_TZ |
Weekday heartbeat time (default 8 / Europe/Vilnius) |
COUNCIL_REPO |
Council repo for read-only heartbeat context (default codebridger/subturtle-docs) |
AGENT_GIT_EMAIL |
Git author email for the agent's history commits |
MODEL_PM / MODEL_HEARTBEAT |
Model tiers (default sonnet / opus) |
CLICKUP_WORKSPACE_ID, NAVID_USER_ID, SOMI_USER_ID, *_CHANNEL_ID, SUBTURTLE_APP_LIST_ID |
Workspace, team, and routing ids |
The webhook signing secret is not in .env — webhook register writes it to data/webhooks.json.
Most of data/ is git-ignored runtime state; the agent's history (threads/, heartbeats/)
is the exception — it's version-controlled in this repo.
data/
threads/ # per-thread episodic memory + INDEX.md [tracked in git]
heartbeats/ # the agent's beat logs, <date>.md [tracked in git]
webhooks.json # active webhook id + signing secret (ignored — secret)
tls/ # self-signed origin cert (ignored — secret)
events/inbox.jsonl # Category-B activity awaiting the PM check (ignored)
poller/cursors.json # last-seen chat message per channel (ignored)
schedule.json # last PM check / heartbeat run (ignored)
schedules.json # the agent's own recurring jobs (scheduler) (ignored)
council/ # read-only clone of the council repo (ignored)
- M1 — Skeleton ✅ headless runner + manual triggers.
- M2 — Webhook ✅ signed listener + loop guard + inbox + thread memory + chat poller (this).
- M3 — Rhythms ✅ PM check every 5h draining the inbox + weekday heartbeat with beat logs (this).
- M4 — Self-management — self-improvement PRs, agent-driven webhook register/unregister, scheduled restart, and self-scheduled recurring jobs (the agent creates its own cron jobs from chat).