← 所有项目

neoworks operator

an agent that works my freelance inbox: drafts every reply for my approval, hands builds to an engineer agent

时间
2026年7月 – 现在
对象
my own freelance business
角色
solo: design, build, deploy, operate
技术
TypeScript, Claude Agent SDK, Postgres, Prisma, Hono, React PWA, Cloudflare Workers, Telegram Bot API
规模
153 commits, 47k lines, 3 apps + 1 package, 24 migrations
接单作品,neoworks

这篇只有以下语言: english

what it is

neoworks is my freelance business. almost all of the work comes in through a korean freelance marketplace, and every job starts as a chat thread there. this is the system that sits on that inbox. it syncs every thread, runs one agent per customer that reads the conversation and drafts a reply in korean, and sends the actual building to a background engineer agent. everything a customer would see goes through an approval gate: a card on telegram or a push notification from the dashboard.

it is one node process (the gateway), a react PWA it serves, a side process for telegram bots, and a cloudflare worker for egress. by mid-july it had run 2,024 agent turns and created 1,295 approvals. i denied 219 of them.

the hard parts

getting in at all. the marketplace is behind cloudflare's WAF and my server's IP gets a 403. i started with ssh SOCKS tunnels, which needed babysitting. in july i replaced them with a 77-line cloudflare worker that re-fetches the site from cloudflare's edge, only for that domain, behind a shared secret. a week later it started failing with 429s. the free tier's 100k requests a day is shared by all ~20 workers on an account. it now has an account to itself.

not logging myself out. the API client shares one cookie file between processes behind a file lock. the subtle part: every full password login rotates the marketplace's session family and kills the sibling web session. so a refresh that fails on a cloudflare 403, a 429 or a timeout counts as transient and gets retried later. only a real 401 from the refresh endpoint may trigger a re-login.

turns that die quietly. each turn is a claude code subprocess. early on, finished work got lost: with a plain string prompt the SDK closes stdin at the first result, so when the background engineer finished, every gateway tool call failed with "Stream closed". turns now run on an input channel that stays open until background work reports back. in august a customer still sat unanswered for 5h40m. resuming a subagent produced a second completion notice but no second start event, so the gateway thought nothing was pending and closed the channel. the dead calls came back as normal results, so the run was saved as a success. now the task gets re-registered, and any turn with dead calls fails and resumes.

the box freezing. PM2 said "online" while the gateway did nothing for 8 to 65 minutes at a time. each turn is a subprocess plus its subagents, and 4 to 6 at once pushed an 11GB host that also runs the customer containers into swap. separately, the process grew past 6GB, got OOM-killed, and systemd's default OOM policy then restarted every PM2 app on the box every ~21 minutes. the real cause was one health query selecting full prompt text for 65 customers: 2.9GB of strings. that query now takes ~185MB.

timestamps truncated to the minute. a customer pasted a quote request as three messages 18 seconds apart. the draft passed the "is there anything newer" gate, because messages 2 and 3 did not look newer than message 1. the gates now compare arrival time plus message id.

what i did

in july i wrote a 10-phase plan and shipped all of it in one push, each phase behind its own flag so a rollback is a setting, not a revert:

  • folded the separate heartbeat process into the gateway as a job framework, so the operator sweeps are just agent runs
  • moved 49 customer project folders (the plan assumed 8) into one tree, each with a metadata.json. 47 containers running before, 47 after
  • memory as small files the agents write themselves, with an index injected per turn and a weekly compaction job
  • sessions rotate on idle through a read-only "memory flush" turn instead of dying at context overflow
  • a Postgres ledger under the in-memory queue, so a restart loses no message

approvals stay the core. auto-approve is a slider, from a closed list of bare acknowledgements up to everything, and everything above bare acks waits out a veto window first. after a buyer's turn died 4 seconds in on a billing error and nobody noticed for over two hours (the buyer went elsewhere), i added a front desk: any message unanswered for 10 minutes gets a short reply-only turn beside the main one.

later the model accounts moved out into a separate router, turns that claude refuses on policy grounds re-run once on DeepSeek, and a daily self-review can change strategy text, but only if the change beats the current text on replayed past conversations, and every change has a one-tap revert.

where it is now

live and used every day. i decide from telegram, the agents read, draft, build and follow up. the gateway restarts itself at 2am korea time, but only once no turn is in flight. most commits since are fixes like the ones above, each with its incident written down.