{"slug": "show-hn-scheduled-claude-code-agents-that-cost-nothing-on-a-quiet-day", "title": "Show HN: Scheduled Claude Code agents that cost nothing on a quiet day", "summary": "Shadok-ai, a new open-source tool launched on Hacker News, lets users run multiple Claude Code agents in parallel on an existing Claude subscription, with each agent in its own git worktree and the ability to spawn sub-agents. The tool, built largely by agents running inside itself, includes features like Telegram mirroring, scheduled prompts with a guard that runs without the model to avoid token costs, and guardrails that can forbid actions like git commit. It requires Node ≥ 20 and the Claude CLI, and auto-installs tmux for agent survival across server restarts.", "body_md": "Run **real** Claude Code sessions side by side, each in its own git worktree,\non the Claude subscription you already pay for. When one of them has a question,\nyou answer it with a click — in the browser, or from Telegram when you are not\nat the desk.\n\n`npx shadok-ai`\n\n**It does not stop at one.** An agent can start other agents — each with its own\nworktree, branch and role — and it is told when one of its own finishes, blocks on\na question, or dies. Nothing else wakes it.\n\n**It keeps working when you are not there.** Mirror an agent to a Telegram topic\nand answer from your phone, one topic per agent. Put a prompt on a schedule with a\n**guard that runs without the model**: prints nothing, nothing happened, zero\ntokens spent.\n\n**It stays on a leash.** A profile is a role plus guardrails — forbid `git commit`\n\nand the agent cannot commit. Secrets live in a vault outside your repository and\nare injected only into the agents that need them. Quota gauges show the 5h and 7d\nwindows, and can hold an agent back when it is burning faster than the window\nelapses.\n\n**It wrote itself.** shadok-ai is largely built by agents running inside shadok-ai,\nin their own worktrees, landed as reviewed pull requests. The section below is what\nthat produced.\n\n```\nnpx shadok-ai\n```\n\nIt **opens your browser** for you once the server is listening — at the port it actually landed on, which is not always 3789 (a busy port walks to the next free one). Only on the launch you asked for: an auto-update respawn never pops a tab. Skipped where it would make no sense — in a container, over SSH, or on a Linux box with no display — and `--no-open`\n\nturns it off.\n\n**Prerequisites:** Node ≥ 20. shadok-ai drives the\n[ claude](https://claude.com/claude-code) CLI on\n\n**your** Claude subscription — if it isn't installed, the cockpit installs it for you on the first agent launch (\n\n`npm i -g @anthropic-ai/claude-code`\n\n; if that can't write the global dir it\ntells you the one command to run). Either way you sign in to your Claude account\n**once** on first use — that step is yours and can't be skipped (see below).\n\n`tmux`\n\nmakes agents survive the server restarting, so shadok-ai **auto-installs it** at boot if it's missing (\n\n`brew`\n\non macOS, `apt`\n\n/`apk`\n\n/`dnf`\n\n/`pacman`\n\non\nLinux — with `sudo`\n\nwhen not root). If it can't (no package manager, or a sudo\npassword it can't provide), it falls back to node-pty and tells you the one\ncommand to run. `SHADOK_TMUX=0`\n\nskips all of that.On the first run it asks once for an optional **Telegram bot token** (press\nEnter to skip; you can add it later from the web UI).\n\n| Flag | Effect |\n|---|---|\n`--port, -p <n>` |\nHTTP/WS port (default 3789; falls back to the next free one) |\n`--no-telegram` |\nweb-only; don't prompt for or use a bot token |\n`--no-open` |\ndon't open the browser on launch |\n`--password <p>` |\nrequire this password to open the GUI (stored in config) |\n`--version, -v` · `--help, -h` |\nversion / help |\n\n**Channels**— one`claude`\n\nprocess each, running in parallel. Renaming, grouping, and closing all sync live between the browser and Telegram: it's one list, server-owned, not two copies.**Worktree isolation**— spawned agents get their own git worktree and branch by default, so parallel agents never collide. Work is never auto-discarded: an empty worktree is reclaimed on close, anything with changes or commits stays (and`Recover`\n\nreopens it).**Interactive dialogs**— the TUI's permission prompts and multiple-choice questions become clickable buttons in the chat, and an inline keyboard in Telegram.**Agent profiles**— a named bundle of role prompt, permission guardrails (e.g. forbid`git commit`\n\n), model, and which secrets to inject. Applied at spawn, remembered across resume.**Secret vault**— stored under`~/.shadok-ai`\n\n, never in your repo, injected as env vars into the agents that need them. An agent that**obtains** a credential (a CLI login, a key it just provisioned) can add it to the vault itself so the next agent doesn't start from nothing — write-only, the value piped in rather than typed as an argument, and never overwriting an existing name without you saying so.**Scheduled prompts**— give a channel a recurring prompt (every N minutes, or daily at HH:MM in a time zone you choose) for monitoring and reporting. Each schedule can carry a**deterministic guard command** that runs*without the model*: prints nothing → nothing to report, the agent is never woken and the run costs**zero tokens**; prints something → that output is prepended to the prompt and the agent runs. A watcher that is quiet most of the day costs nothing most of the day.**Agents that report back**— an agent that launches other agents is told when each one finishes, blocks on a question, or dies. It hears about** its own**children and nothing else, so a busy channel never wakes it. What arrives is the child's own summary plus pointers (branch, diff link) — never the diff itself, which on a large session costs far more than it is worth. The link is set automatically at spawn; you can also attach or detach an agent by hand from its ⋯ menu.**Quota gauges + pace guard**— 5h and 7d subscription usage, with an optional block when you're burning faster than the window elapses (any message can force through).**Notifications**— favicon, title badge and an optional sound when an agent needs you. It only blinks when the tab is hidden*and*an unmuted channel is actually waiting.**Engine room**— the raw TUI screen, live, with clickable keys, for anything the chat can't express. With tmux there is also an** experimental real terminal**(xterm.js over the pane's byte stream) when a snapshot isn't enough.** Diff panel**— what an agent actually changed, against its base.** Context sent**— a collapsed block at the head of every conversation showing what shadok added to that agent: its role, its guardrails and permission mode, the**names** of the variables injected (never the values), the cockpit prompt. It also lists the**capabilities** shadok installs — the skills that let an agent start other agents, schedule its own prompts or store a credential — by description, since that is what Claude Code actually holds in context. Captured**at spawn**, so it is what the running process was actually given — not a recomputation that would quietly show today's profile. Also on the ⋯ menu of each channel. It says plainly that it is shadok's half: Claude Code adds its own context, which shadok never sees.**Phone layout**— the cockpit is a fixed chassis that never pans sideways, sized on the*visual*viewport so the composer stays clear of the browser's own bar and, when the keyboard opens, sits right above it (the instrument bar folds away while you type). Fields are 16px, which is what stops Safari from zooming in on focus. The agents column is replaced by a channel selector in the channel bar.**Tweak Shadok-AI**— a card pinned at the bottom of the agents column. One click clones shadok-ai's own source into`~/.shadok-ai/self/shadok-ai`\n\nand starts an agent on it in its own worktree; that agent delivers its change as a**pull request**— a fork under your GitHub account, since you need no rights on the repo. Nothing to configure and no token to paste: the clone is anonymous, so you describe an idea, watch it work and read the diff first, and it only asks for GitHub (via`gh auth login`\n\n, device code relayed in the chat) when there is something worth pushing. It is**one channel, not a launcher**: the card becomes that channel's tab, with the usual menu — mute, reload, rename, change profile, mirror to Telegram, close — and closing it brings the card back. Desktop only: the agents column is hidden on phones.**Guided tour**— on a browser's first visit, a welcome card explains what shadok-ai is, then a spotlight walks the real controls: the agents column, an agent's ⋯ menu, the toolbar, the quota dials. Skip and Escape end it at any point; ⋯ →*Guided tour*replays it. A step whose target isn't on screen is**dropped rather than faked**, so the phone (no agents column) and an empty cockpit (no agent tab) get a shorter tour instead of a spotlight on nothing. On a brand-new instance it comes**before** the sign-in card — being asked to authorise an OAuth flow before you know what the thing is takes it backwards.**A lead agent from the start**— an instance with no channel at all gets one:`general`\n\n, running the**Shadok-Boss** profile, in the launch directory and without a worktree. It is created at boot, or right after the first sign-in on a brand-new instance (which is signed out at boot, so there is nothing to spawn yet). The condition is \"no channel\", which makes it idempotent: it never appears twice. Nothing is prompted — the agent waits for you. It is the environment's**home base**: pinned at the top of the list, with no close button and no*Close agent*in its menu. You can still reload, rename, re-profile or mute it — \"permanent\" should not mean \"stuck\". A cockpit that already had a`general`\n\nat its launch directory adopts it on the next start, and only ever when exactly one channel matches: a wrong adoption would be irreversible from the UI, since that channel becomes the one you cannot close.**Self-update**— polls npm and can update and reload itself in place.\n\nAdd your bot to a group with **Topics** enabled, make it an admin with *Manage\ntopics* (and *Delete messages* so `/secret`\n\ncan scrub values), then in the group:\n\n| Command | |\n|---|---|\n`/setup` |\nbind this group as the board (one group per instance) |\n`/spawn [profile] <name>` |\nnew isolated agent in its own topic (also a web tab) |\n`/stop` (alias `/esc` ) |\ninterrupt the current turn — does not end the session |\n`/new` · `/end` |\nreset · kill the session |\n`/restart` |\nrespawn the agent in place (e.g. to pick up new secrets) |\n`/profiles` · `/list` |\nlist profiles · list bindings |\n`/cron every 30m <prompt>` · `/cron daily 09:00 <prompt>` |\nschedule a recurring prompt on this agent |\n`/cron once 2026-08-25T08:42 <prompt>` |\nfire it once on that date, then the schedule is left disabled and marked `(fired)` |\n`/cron list` · `/cron on|off|del <id>` |\nmanage them (`<id>` accepts the printed 8-char prefix) |\n`/secrets` · `/secret KEY value` · `/unsecret KEY` |\nthe secret vault |\n`/update` |\nfetch `@latest` and respawn |\n\nCreating a topic by hand also spawns an agent. Anything that isn't a command is sent to that topic's agent as a prompt — including photos and files, which are downloaded and handed to Claude Code.\n\n**Direct messages belong to one person.** The first user to DM the bot claims it;\neveryone else is refused. On startup the owner is adopted from an existing DM or\nfrom the board group's creator, so an instance that already has an owner never\nhands itself to whoever messages next — a bot username is public, and a DM is a\nshell.\n\nThe repo ships an official `Dockerfile`\n\n:\n\n```\ndocker build -t shadok-ai .\ndocker run -d --name shadok-ai --restart unless-stopped \\\n  -p 127.0.0.1:3789:3789 \\\n  -v shadok-data:/root/.shadok-ai \\\n  -v shadok-claude:/root/.claude \\\n  -v shadok-workspace:/workspace \\\n  -e SHADOK_HOST=0.0.0.0 -e SHADOK_GUI_PASSWORD=change-me \\\n  shadok-ai\n```\n\nThe image bundles **Claude Code**, **shadok-ai** and a **headless browser**\n(Playwright Chromium, at `PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers`\n\n) so\nan agent can screenshot or drive the web with no first-run download; `git`\n\n, `gh`\n\n,\n`tmux`\n\n, `jq`\n\nand a C toolchain are there for agents' own work. The three volumes\nare what survive `docker restart`\n\n**and** `docker rm`\n\n+recreate: `~/.shadok-ai`\n\n(vault, profiles, channels, crons, SSH identity), `~/.claude`\n\n(credentials,\ntranscripts), `/workspace`\n\n(agents' files). A fresh container needs a one-time\nClaude login: `docker exec -it shadok-ai claude`\n\n.\n\nThe cockpit runs arbitrary commands on the host by design, so it binds\n`127.0.0.1`\n\n— **this machine only** — unless you say otherwise. Two things to\nknow before opening it up:\n\n`SHADOK_HOST=0.0.0.0`\n\n**requires a password**(`--password`\n\n, or`SHADOK_GUI_PASSWORD`\n\n); without one the server refuses to start rather than hand the network a shell.**In Docker**,`SHADOK_HOST=0.0.0.0`\n\nis the only value that works (the container's own loopback isn't reachable from the host). Publish the port on the host's loopback:`-p 127.0.0.1:3789:3789`\n\n. Plain`-p 3789:3789`\n\npublishes on every host interface**and bypasses ufw/firewalld**, because Docker inserts its own iptables rules upstream of them.\n\nBrowsers may only talk to the cockpit from its own origin — a WebSocket ignores\nthe same-origin policy, so without that check any page you happen to visit could\ndrive your agents. Behind a reverse proxy that rewrites `Host`\n\n, list the public\norigin in `SHADOK_ORIGINS`\n\n.\n\nServing the cockpit over HTTPS works, with one requirement: **the proxy must\nforward the WebSocket upgrade**. Everything live — the channel list included —\ntravels over `/ws`\n\n, and the page itself is static HTML, so a proxy that drops\n`Upgrade`\n\n/`Connection`\n\nproduces a cockpit that loads perfectly and then never\nconnects. In nginx:\n\n```\nlocation / {\n    proxy_pass http://127.0.0.1:3789;\n    proxy_http_version 1.1;\n    proxy_set_header Upgrade $http_upgrade;\n    proxy_set_header Connection \"upgrade\";\n    proxy_set_header Host $host;\n}\n```\n\nThe client picks `wss://`\n\non its own when the page is HTTPS, so there is nothing\nto configure on that side.\n\n**Idle timeout.** A resting agent's `/ws`\n\ncarries no traffic, and proxies\nidle-close a quiet socket (nginx `proxy_read_timeout`\n\n, 60s by default;\nCloudflare ~100s) — the symptom is a cockpit that keeps flashing\n\"reconnecting…\" even though nothing is wrong. The server sends a WebSocket ping\nevery 25s (tunable with `SHADOK_WS_PING_MS`\n\n) to keep the connection warm, so no\nproxy change is normally needed; if your proxy still cuts it, raise\n`proxy_read_timeout`\n\nabove the ping interval.\n\nA fresh machine — most often a fresh container — has two things in its way, and shadok handles both so you never need a shell on the host.\n\n**The first-run screens are pre-answered.** A virgin `claude`\n\nopens on a theme\npicker, then on a per-directory trust dialog, and an agent started there never\nreaches a prompt: no input box, every prompt fails, the tab reads \"failed to\nstart\". shadok seeds the answers in `~/.claude.json`\n\n— at boot for the global\nones, and before every spawn for the session's directory, because a worktree is\na brand-new directory and therefore a brand-new trust dialog. The seeding is\n**purely additive**: a value already there is never overwritten, so on a machine\nthat has used Claude Code before, nothing is written at all.\n\n**The sign-in is offered in the cockpit.** When the instance isn't signed in, a\ncard takes over the page with the OAuth link and a field for the code you get\nback; agents refuse to start until it's done, so you can't accidentally\nmanufacture an agent with no credentials. The card exists only while it's\nneeded — there is no button that opens it when everything is fine.\n\n**From Telegram**, which is where you usually are when a session signs out\nmid-life:\n\n```\n/login          → replies with the link\n/code <code>    → finishes the sign-in (your message is deleted afterwards)\n```\n\nBoth are restricted to the bound board group or the owner's DM, like `/secret`\n\n—\nan OAuth code grants access to the account. The sign-in is instance-global, so\nthe link is the same on both sides and a code pasted in Telegram also closes the\ncard open in a browser. When a spawn is refused for lack of credentials, shadok\nposts **one** message to the board group; it won't repeat until the state flips\nback.\n\n**In Docker**, this replaces the old recreate ritual. `/root/.claude.json`\n\nis\nnot on a volume, so `docker rm`\n\n+ recreate used to lose the onboarding state and\nthe respawned agents landed on the first-run screen — which meant restoring the\nfile *before* starting the container (`docker create`\n\n→ `docker cp`\n\n→\n`docker start`\n\n). That ordering is no longer necessary: shadok writes the file\nitself, before any session spawns.\n\nWhen shadok-ai runs **in a container** it gives itself an SSH key on first boot\nso agents can `git clone/push`\n\nprivate repos and `ssh`\n\ninto servers. The key\nlives under `~/.shadok-ai/ssh/`\n\n— i.e. on the ** shadok-data volume you already\nmount** — so it\n\n**survives**(unlike a plain\n\n`docker restart`\n\nand `docker rm`\n\n+recreate`~/.ssh`\n\n, which is wiped on recreate). Each container has its own volume,\nhence its own unique key; `~/.ssh`\n\nis symlinked to it, so `git`\n\n/`ssh`\n\nuse it with\nno extra config. Nothing to add to `docker run`\n\n.Read the public key to register it (GitHub **deploy key**, or the target hosts'\n`authorized_keys`\n\n):\n\n```\ndocker logs <name> | grep 'ssh identity'          # printed on every boot\ndocker exec <name> cat /root/.shadok-ai/ssh/id_ed25519.pub\n```\n\nOn a normal (non-Docker) host this is a **no-op** — shadok never touches your\n`~/.ssh`\n\n. Detection is `/.dockerenv`\n\n; `SHADOK_SSH_IDENTITY=0`\n\ndisables it,\n`SHADOK_FORCE_SSH_IDENTITY=1`\n\nforces it on.\n\nConfig lives in `~/.shadok-ai/config.json`\n\n(mode 600) and is **authoritative\nover the environment once set** from the GUI. The Telegram token, allowed\nchats, and the bridge on/off switch are **per launch directory** — running the\nserver from another repo gives you a different cockpit and a different bot. So\nare the channel list and the scheduled prompts.\n\n`timezone`\n\n(an IANA name like `Europe/Paris`\n\n, settable via `/timezone`\n\n) is the\ndefault zone for reading a `daily`\n\nschedule. Without it the hour follows the\nmachine, which silently shifts every daily prompt on a server running in UTC.\n\n| Env var | |\n|---|---|\n`PORT` |\nHTTP/WS port |\n`SHADOK_HOST` |\ninterface to bind (default `127.0.0.1` , this machine only) |\n`SHADOK_GUI_PASSWORD` |\nrequire a password for the GUI |\n`SHADOK_ORIGINS` |\nextra browser origins allowed, comma-separated (reverse proxy) |\n`SHADOK_TMUX=0` |\nforce the node-pty transport instead of tmux |\n`SHADOK_IDLE_MIN` |\nminutes with no client before a session is reclaimed (60) |\n`SHADOK_PERMISSION_MODE` |\nmode new agents start in (default `acceptEdits` ) |\n`SHADOK_AUTOUPDATE` |\nfallback only — the GUI setting wins once used |\n`SHADOK_PILOT_PROMPT=0` |\ndon't inject the cockpit system prompt |\n`SHADOK_LEDGER=1` |\nopt into the shared-ledger reflex (OFF by default; the GUI/config setting wins once used) |\n`SHADOK_RESUME_SUMMARY=1` |\ndon't auto-answer the resume-from-summary prompt |\n`SHADOK_SSH_IDENTITY=0` · `SHADOK_FORCE_SSH_IDENTITY=1` |\ndisable / force the Docker SSH identity |\n`TELEGRAM_BOT_TOKEN` · `TELEGRAM_ALLOWED_CHATS` |\noverride the stored config |\n`CLAUDE_CODE_OAUTH_TOKEN` |\nonly for the usage gauges; `claude` itself uses the keychain |\n\nHacking on shadok-ai?Read[(map, build/run, invariants) and]`CLAUDE.md`\n\n[first.]`docs/architecture.md`\n\nThree documents, three jobs — and **they ship with the change that makes them\nwrong**, not in a catch-up pass afterwards:\n\n| Document | Holds | Update it when |\n|---|---|---|\n`README.md` |\nwhat shadok-ai does and how to drive it | a user-visible feature, flag, command, endpoint or protocol message changes |\n`CLAUDE.md` |\nthe file→responsibility map, the build/run recipe, the invariants | you add a module, or you lose an afternoon to something the next person would lose it to as well |\n`docs/architecture.md` |\nhow a subsystem actually works and why it was built that way | you add or reshape a subsystem, or you make a design trade-off worth remembering |\n`docs/superpowers/specs/` |\none design per feature, dated | before building anything non-trivial |\n\nThis is not bookkeeping. A doc that lags is worse than no doc: it is confidently\nwrong, and it is read as current. `docs/architecture.md`\n\nonce went **48 commits**\nwithout an update, and by then it was missing entire subsystems while its line\nreferences pointed at code that had moved hundreds of lines — a reader in that\nwindow gets misled, not merely under-informed.\n\nTwo habits that keep it cheap:\n\n**Cite symbols, not just line numbers.**`finishTurn`\n\nsurvives a refactor;`server.ts:781`\n\ndoes not. Where a line number helps, say which commit it was read at.**Write the** The what is in the diff. What the diff cannot say is which alternative you rejected and what it cost you to find out — that is the whole value of*why*, not the*what*.`architecture.md`\n\n, and the reason the invariants list reads like a scar tissue map.\n\nDrives the **Claude Code TUI** (the interactive `claude`\n\nCLI) through a real\nterminal, and reads the answers from Claude Code's own transcript.\n\n⚠️ The Claude Code TUI is not a stable API: a CLI update can break the detection heuristics (`❯`\n\n,`⏺`\n\n,`esc to interrupt`\n\nmarkers). For production use, prefer the[Claude Agent SDK]or`claude -p --output-format stream-json`\n\n.\n\nTwo transports, same interface:\n\n**tmux (default when installed)**—`claude`\n\nruns in a detached tmux session, so it survives the server restarting or crashing and is reattached on the next start. tmux answers terminal queries itself, which removes a whole class of PTY hacks. It does not survive a machine reboot.**node-pty**—`claude`\n\nruns as a child of the server inside a pseudo-terminal (the TUI believes it talks to a human), with**@xterm/headless** replaying the ANSI stream into a virtual screen we can read. Terminal query responses (cursor position`\\x1b[?6n`\n\n, identification`\\x1b[c`\n\n…) must be forwarded back to the PTY, or the TUI ignores every keystroke. Dies with the server.\n\nIn both cases prompts are sent via **bracketed paste** with retries (the TUI\nflushes stdin received during initialization), and \"Claude is done\" is detected\nheuristically: no `esc to interrupt`\n\nmarker + screen stable for N ms.\n\n**Responses are not scraped from the screen.** Claude Code writes every turn to\na `.jsonl`\n\ntranscript; that file is the source of truth for chat content, which\nis what makes long answers reliable. The screen is used only for control —\nsubmitting, detecting the end of a turn, reading dialogs, and mirroring the raw\nTUI in the engine room.\n\n```\nnpm install\nnpm run build      # tsc → dist/\nnpm test\nnpm run web        # run the server directly (no supervisor, no auto-update)\n```\n\nAn instance follows one of two release streams, picked in the version menu (click the version next to the brand):\n\n**beta**— the default, and what a fresh`npx shadok-ai`\n\ninstalls. Moves only when a version is promoted, so the instance stays put for days at a time.**alpha**— moves on every merge to`main`\n\n. Newest work, freshest bugs.\n\nPromoting is deliberate: someone bumps the minor in `package.json`\n\nand merges,\nand that merge becomes the new beta for everyone.\n\nThe numbers tell you which channel a version came from. An alpha carries the\ncommit count (`0.3.78`\n\n); a promotion is a round `0.4.0`\n\n. The auto-update switch\nand the reload behaviour are identical on both.\n\nThe `postinstall`\n\nscript fixes the executable bit of node-pty's\n`spawn-helper`\n\n(a known npm prebuilds bug on macOS).\n\nNote that `npx shadok-ai`\n\ndoes **not** run your working tree: it runs a\nsupervisor that manages an auto-updating npm-installed copy. To test a local\nbuild, see \"Running YOUR build\" in [ CLAUDE.md](/shadok-ai/shadok-ai/blob/main/CLAUDE.md).\n\nSeparate from the cockpit: run a single prompt and print the answer.\n\n```\nshadok-ai-run [options] \"<prompt>\"      # or: node dist/cli.js …\n\n  --cwd <dir>       working directory of the claude session\n  --continue, -c    resume the latest session of this directory\n  --resume <id>, -r resume a specific session (id printed at end of run)\n  --watch           mirror the TUI live on stdout\n  --keep            keep the session open at the end\n  --timeout <sec>   max wait for the response (default 600)\n```\n\nWithout `--continue`\n\n/`--resume`\n\n, every run starts a **new** session. The\nsession id is printed at the end of the run (found in\n`~/.claude/projects/<encoded cwd>/`\n\n, like any Claude Code session):\n\n```\nshadok-ai-run --cwd ~/my-project \"Explain this project's structure\"\n# ▶ session: 5fe046dd-…\n\nshadok-ai-run --cwd ~/my-project --resume 5fe046dd-… \"Now refactor it\"\nshadok-ai-run --cwd ~/my-project -c \"Continue on the latest session\"\n```\n\nThe interface is a plain static page: all the intelligence lives server-side.\nAny client (another front-end, a bot, a script…) can replace it by speaking\nthis JSON protocol on `ws://…/ws`\n\n. The built-in Telegram bridge *is* such a\nclient — it connects to the same server over loopback.\n\n**Shared sessions**: the server keeps a single claude process per session id.\nIf several clients (tabs, browsers, interfaces) `start`\n\nthe same id, they\nattach to the same process and all receive the same events — other clients'\nprompts (`prompt-echo`\n\n), answers, dialogs, screen. Closing a connection\ndetaches the client; the session is reclaimed after `SHADOK_IDLE_MIN`\n\nwith no\nclient, or immediately on an explicit `stop`\n\n(which ends it for everyone).\n\n**client → server**\n\n| Message | Purpose |\n|---|---|\n`{type:\"start\", cwd?, resume?, continue?, worktree?, branch?, repo?, profile?, parent?, origin?}` |\nstarts or attaches to the session (once per connection). `origin` (`\"web\"` , `\"cron\"` , `\"telegram\"` , `\"cli\"` …) travels with `prompt-echo` so other clients can say who spoke. `parent` records who launched this agent — a refused link is dropped and logged, never fatal to the spawn |\n`{type:\"prompt\", text, force?}` |\nsends a prompt (`force` bypasses the pace guard) |\n`{type:\"choose\", n}` |\nsingle-select dialog: picks and validates option n |\n`{type:\"toggle\", n}` / `{type:\"confirm\"}` |\nmulti-select: toggles option n / submits |\n`{type:\"freetext\", n, text}` |\n\"Type something\" option: sends a free-form answer |\n`{type:\"key\", key}` |\nraw keystroke (`enter` , `escape` , `up` , `down` , `tab` , `ctrl-c` , or a single character) |\n`{type:\"settle\"}` |\nafter a manual intervention: waits for the turn to finish |\n`{type:\"restart\"}` |\nrespawns the agent in place (picks up new secrets/profile) |\n`{type:\"set-parent\", parent}` |\nattach this channel under another (`null` detaches). Refused explicitly on a self-link, a cycle, an unknown parent, or a chain/fan-out past its cap |\n`{type:\"term-attach\"}` · `{type:\"term-detach\"}` |\nexperimental, tmux only — open/close the pane's raw byte pipe |\n`{type:\"term-input\", data}` · `{type:\"term-resize\", cols, rows}` |\nraw input (base64) / match the pane to the viewport |\n`{type:\"stop\", sessionId?}` |\nends the session for all clients; `sessionId` targets another channel (zombie cleanup) |\n\n**server → client**\n\n| Message | Purpose |\n|---|---|\n`{type:\"ready\", sessionId, cwd}` |\nsession started (or attached) |\n`{type:\"working\"}` / `{type:\"turn-done\", sessionId}` |\nturn started / finished |\n`{type:\"stream-text\", text, at?}` |\na complete assistant text block, from the transcript. `at` is when it was written, not when we read it |\n`{type:\"stream-tool\", id, name, summary}` / `{type:\"stream-result\", …}` |\ntool call / tool result |\n`{type:\"tokens\", tokens}` / `{type:\"context\", pct}` |\ntoken usage / context fill |\n`{type:\"parent\", parent}` |\nthe channel's parent changed (broadcast, so every tab follows) |\n`{type:\"prompt-echo\", text}` |\nprompt sent by another client of the session |\n`{type:\"dialog\", question, options:[{n,label,hint,checked?}], multi}` |\nchoice pending |\n`{type:\"history\", turns:[…]}` |\ntranscript replayed when resuming/attaching |\n`{type:\"screen\", text, working}` |\nrendered TUI screen (whenever it changes) |\n`{type:\"pace-blocked\"}` / `{type:\"pace-hold\"}` / `{type:\"pace-resumed\"}` |\nquota guardrail |\n`{type:\"auto-retry\"}` / `-cancelled` / `-gave-up` |\ntransient API error being retried |\n`{type:\"version\", …}` / `{type:\"server-reload\", version}` |\nupdate available / server updated, reload |\n`{type:\"term-data\", data}` |\nexperimental — raw pane output (base64) for a client-side terminal emulator |\n`{type:\"gone\"}` / `{type:\"error\", message, code?}` / `{type:\"exited\", code}` / `{type:\"stopped\"}` |\nsession lost, errors, termination. `error.code` is `\"busy\"` for a prompt refused mid-turn and `\"logged-out\"` for a spawn refused because the instance isn't signed in — so a machine client needn't match on the message text |\n\nHTTP endpoints (same auth): `/usage`\n\n, `/live`\n\n, `/sessions`\n\n, `/recover`\n\n,\n`/diff`\n\n, `/channels`\n\n(its GET adds a **derived** `crons`\n\nfield — never stored),\n`/channel`\n\n(DELETE), `/groups`\n\n, `/crons`\n\n, `/timezone`\n\n, `/title`\n\n(GET/PUT — the\ncockpit's name, per launch directory: click the header brand to rename it, so\nseveral cockpits stay apart in the tab bar; an empty PUT reverts to the default),\n`/theme`\n\n(GET/PUT — the cockpit's colour palette, per launch directory: an accent\nkey picked from the ⋯ menu, e.g. `emerald`\n\n; unknown/`amber`\n\nreverts to default),\n`/profiles`\n\n, `/secrets`\n\n, `/telegram`\n\n, `/defaults`\n\n, `/version`\n\n, `/autoupdate`\n\n,\n`/update-channel`\n\n(POST `{channel}`\n\n— `alpha`\n\nor `beta`\n\n), `/permission-mode`\n\n, `/tweak/prepare`\n\n(POST — clone/refresh shadok-ai's own source,\nreturns the cwd to start the tweak agent in), and the sign-in group: `/auth`\n\n(GET\n— `{loggedIn, email?, subscriptionType?}`\n\n), `/auth/login`\n\n(POST — start a flow,\nreturns `{url}`\n\n; DELETE — cancel it), `/auth/code`\n\n(POST `{code}`\n\n).\n\n``` js\nimport { PtyPilot } from \"shadok-ai\";\n\nconst pilot = new PtyPilot({ cwd: \"/my/project\" });\npilot.start();\nawait pilot.waitForIdle();                 // TUI ready\n\nawait pilot.submit(\"Fix the bug in auth.ts\");\nawait pilot.waitForIdle({ timeoutMs: 600_000 });\nconsole.log(pilot.screen());               // rendered screen (visible transcript)\n\nawait pilot.submit(\"Now add a test\");      // same session, second turn\nawait pilot.waitForIdle();\n\nawait pilot.stop();                        // clean /exit, kill as fallback\n```\n\nMain API:\n\n| Method | Purpose |\n|---|---|\n`start()` / `stop()` / `kill()` |\nprocess lifecycle |\n`submit(text)` |\ntypes a prompt + Enter, with verification and retries |\n`write(text)` / `press(key)` |\nlow-level keystrokes (`enter` , `escape` , `up` , `tab` , `ctrl-c` …) |\n`screen()` / `fullBuffer()` |\nrendered screen / full buffer |\n`waitForIdle({stableMs, timeoutMs})` |\nwaits for the end of a turn |\n`waitFor(predicate)` |\nwaits for an arbitrary screen condition (dialogs, permissions…) |\n`isWorking()` |\ntrue while Claude is working |\n`onData(cb)` / `onExit(cb)` |\nraw ANSI stream (mirror mode) / process exit |\n\n`TmuxPilot`\n\nexposes the same surface and additionally survives a server\nrestart. `examples/two-turns.mjs`\n\nshows a two-turn conversation;\n`debug/probe.mjs`\n\nlogs the terminal sequences exchanged (useful when a CLI\nupdate breaks the detection).\n\n- Every session launched consumes your Claude quota like a normal session.\n- Profile guardrails are\n**soft**: agents run as the same OS user. It prevents misfires, it is not a sandbox. - Agent worktrees are branched at spawn and never rebased, so a long-running\nagent drifts from a moving main branch. A design for this exists and was\ndeliberately deferred:\n`docs/superpowers/specs/2026-07-28-worktree-rebase-drift-design.md`\n\n. - The interactive terminal (xterm.js over the raw pane stream) is\n**experimental** and requires tmux; with node-pty the engine room is all there is. - Dialogs the chat cannot handle in one click are managed through the\nengine room (\n`waitFor()`\n\n+`press()`\n\nin library mode). - The TUI runs in the alternate screen:\n`fullBuffer()`\n\n≈ visible screen; very long responses scroll out of view (increase`rows`\n\nif needed).\n\nLes Shadoks were French cartoon birds, drawn by Jacques Rouxel in 1968, and the\nwhole joke was that they pumped. Relentlessly, exhaustingly, and for no result\nanyone could ever identify. One of their mottos: *the more it fails, the more\nlikely it is to work.*\n\nAn agent woken every hour to report that nothing happened is pumping. You pay a\nmodel call to be told there was nothing to do, and it will tell you that\ntwenty-three more times today. Hence the guard in `crons.ts`\n\n: the check runs\nwithout the model, and a quiet hour costs nothing at all.\n\nThe name went unexplained here for the project's first month, which is a\nstrange thing to leave out of a README when the joke *is* the design.", "url": "https://wpnews.pro/news/show-hn-scheduled-claude-code-agents-that-cost-nothing-on-a-quiet-day", "canonical_source": "https://github.com/shadok-ai/shadok-ai", "published_at": "2026-08-28 07:53:07+00:00", "updated_at": "2026-08-28 08:18:40.970210+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "artificial-intelligence"], "entities": ["Shadok-ai", "Claude Code", "Claude", "Anthropic", "Telegram", "Node.js", "tmux", "Hacker News"], "alternates": {"html": "https://wpnews.pro/news/show-hn-scheduled-claude-code-agents-that-cost-nothing-on-a-quiet-day", "markdown": "https://wpnews.pro/news/show-hn-scheduled-claude-code-agents-that-cost-nothing-on-a-quiet-day.md", "text": "https://wpnews.pro/news/show-hn-scheduled-claude-code-agents-that-cost-nothing-on-a-quiet-day.txt", "jsonld": "https://wpnews.pro/news/show-hn-scheduled-claude-code-agents-that-cost-nothing-on-a-quiet-day.jsonld"}}