@archangel-tools/agent-worker
v0.2.2
Published
Unattended executor for agent-context organizations: scheduler + spawner + contract-verifier around headless Claude Code sessions. Org knowledge arrives at runtime from the layer and the backend.
Readme
agent-worker
An unattended executor: a THIN daemon — scheduler + spawner +
contract-verifier — around headless Claude Code sessions riding an operator's
subscription OAuth (~/.claude). All task intelligence lives in the
workplace's compiled layer (the execute-task skill); the worker only polls
the release gate, claims, spawns, and enforces the runtime contract
(CONTRACTS.md). ANTHROPIC_API_KEY is deleted from every child environment.
Deployment model
The worker is thin on purpose: one instance per identity+host, serving exactly
the orgs that identity holds credentials for. The package knows nothing about
any organization's content — org knowledge arrives at runtime from the org's
layer (_schema.md bindings) and the backend it points at. Jobs are deployment
config, not package content: point jobs_dir at your own job files; the
packaged jobs.example/ is a template to copy from. Architecture decision:
https://app.notion.com/p/3b7bfb5a7adc8165ace9e582b95a8454
Three credentials never live in a repo; every deployment supplies its own:
- a Notion token, scoped to that org's teamspace, in the env var the config
names (
notion_token_env, defaultNOTION_TOKEN); - git access to that org's repos;
- a Claude login (
claudeCLI logged in, credentials in~/.claude).
Prerequisites
- Node >= 22.5
claudeCLI installed and logged in- a Notion integration whose token can read and write the org's tracker, anchor, and activity-log collections
Runbook A — the vendor cockpit (this checkout)
The vendor runs one worker over every org, from the workspace root that holds all the repos side by side:
# clone the workspace root, then fill it
./bootstrap.sh # every repo, from repos.txt
cd archangel-tools/agent-worker
npm install && npm run build # build from source
mkdir -p ~/.config/agent-worker
cp config.example.yaml ~/.config/agent-worker/config.yaml
# then edit: constellation_root = the workspace root,
# agent_ctx_bin = <root>/archangel-tools/agent-ctx/bin/agent-ctx,
# jobs_dir = <root>/archangel-tools/agent-worker/jobs,
# one org entry per org (default NOTION_TOKEN env)
export NOTION_TOKEN=... # never config, never logged
npx -y @archangel-tools/agent-ctx@latest mirror <org>/agent-context # mirror init
node bin/agent-worker doctor # exit 1 on a hard failure
node bin/agent-worker once --dry-run # what WOULD be claimed/fired; zero writes
scripts/start.sh # nohup + pidfile in ~/.local/state/agent-worker
scripts/stop.sh # SIGTERM; finishes the current run, then exitsRunbook B — a single-org deployment
A client org needs no vendor git access at all — the worker installs from public npm, and the only repos on disk are the org's own:
npm i -g @archangel-tools/agent-worker
# ONLY your org, under one root:
mkdir -p ~/agents && cd ~/agents
git clone <your-git-host>/<org>/agent-context
git clone <your-git-host>/<org>/<workplace> # each workplace the tracker claims
mkdir -p ~/.config/agent-worker
# config: constellation_root: /home/you/agents, orgs: [yours] with your
# notion_token_env — see the packaged config.example.yaml
export NOTION_TOKEN=... # or the var your config names
# YOUR Claude subscription login: `claude` installed and logged in
agent-worker doctor
agent-worker once --dry-run
agent-worker run # foreground; supervise however you likeOptional jobs: copy the packaged jobs.example/ somewhere, replace <org>,
and point jobs_dir at it.
Run
run loops every poll_seconds: due jobs first (jobs_dir/*.yaml — daily
at: or every_minutes:), then ONE task cycle per org — query the org
tracker's Ready gate, resolve the workplace through task → project →
solution → Repos, claim to In progress, spawn claude -p with agent-ctx
recall output injected first and the output discipline appended last, and hand
back to Review. One session at a time, sequential by design.
The prompt a run gets
Every prompt the daemon builds — task or job — is three parts in one fixed
order: the recall block (or the marked <!-- no memory available --> line),
then the body (the task instruction, or a job's own prompt verbatim), then a
closing <output-discipline> block, the same bytes on every run.
That block tells an unattended session to skip preamble and postamble, never to restate code, file contents, diffs or tool output already in the conversation, to continue without narrating a tool call that succeeded, and to spend the words on the journal entry and the final report instead — those are the parts a human actually reads. Everything else is prose written into a log file nobody opens, and it is billed at output rates, several times input on these models.
Two properties of the block are load-bearing:
- It is at the tail. The memory block and the task body ahead of it stay a reusable prompt prefix; the discipline is appended after them, not woven in.
- It is byte-stable. Editing a byte is a cache-busting change across every
job and every task, not a copy tweak.
OUTPUT_DISCIPLINEinsrc/runner.tsis the text; reword it deliberately or not at all.
It governs prose only, and it says so in its own last sentence — it never licenses doing less of the work, skipping a check, or leaving the journal entry thin. A session told to be brief will otherwise hear "be brief about the work".
What a run costs
claude -p --output-format json already prices every run it finishes. The
runner keeps what it reports — total_cost_usd, num_turns and the usage
token breakdown, on RunOutcome.cost — and logs one line per run, to stdout
and to the worker log in the state dir:
cost: $0.4213 turns=12 in=900 out=300 cache_read=41000There is no ledger. Nothing aggregates those lines, nothing budgets against
them, and nothing declines a run for being expensive. This is only the part that
was free and that stopped being recoverable the moment the child process exited:
the number existed, the runner was discarding it, and the sole way back to it
afterwards is re-reading megabytes of transcript. A CLI old enough to report
none of these fields leaves the run unpriced — no line, no cost on the
outcome — rather than failing it.
Jobs
A job is one YAML file in jobs_dir, loaded in filename order:
name— required; the key its last-run timestamp is stored under.at: "HH:MM"(daily, local) orevery_minutes: <n>— exactly one of them.kind: claude | command— aclaudejob goes through the same runner as a task; acommandjob is a plain spawn.cwd— path underconstellation_root; defaults to the root itself.prompt— required forkind: claude; used as the prompt body, verbatim.argv— required, non-empty, forkind: command.allowed_tools— the--allowedToolslist; empty by default.add_dirs—--add-direntries; a leading~/is expanded at run time.permission_mode—acceptEditsby default.max_turns—100by default.
Last-run state lives in jobs.json in the state dir. A daily job fires once
at has passed today and the last run predates it; the attempt is recorded
even when it fails, so a broken job does not re-fire on every poll. A
claude-kind job runs against the first configured org's bindings and token,
and journals itself through its own skill — a missing entry is logged, never
backfilled (only task runs get a backfill).
Pin the spec in any job argv that runs the engine. mirror-refresh shipped
as npx -y @archangel-tools/agent-ctx with no version, so npx served whatever
its cache already held — a build predating the mirror command. The job failed
nightly with "unknown command", the mirror was never refreshed, and every job
and task after it ran with <!-- no memory available -->: a silent no-op that
cost the whole deployment its memory. The argv now says
@archangel-tools/agent-ctx@latest, which is what src/runner.ts already did
for recall.
This checkout's own jobs/ holds three, and their order is deliberate:
nightly-sweep(03:17,claude) — thesweep-activity-logskill over the last 7 days: diff local session transcripts against the Activity Log bysession:token and backfill missing sessions as Draft entries.mine-session-waste(03:27,claude) — after the sweep, so every session in the window is already journalled. It reads this org's session transcripts, canonicalizes each tool call so pagination and truncation differences collapse into one signature, and reports loops of three or more occurrences (error loops and re-fetch loops counted differently). Each distinct signature is filed as a Task in the un-released stage (Not started, neverReady), titled with the guardrail rather than the symptom; an existing open task for the same signature is updated instead of twinned. At most 20 items per run, and it journals itself only if something was filed. It may never write Knowledge, author a decision, or move a task toReady— that is invariant 11 of the event-sourcing pattern, and the reason its output is a proposal a human releases. Look for what it found in the Tasks database, un-released stage. It reads transcripts rather than the mirror, so its slot is about the sweep having finished, not about memory.mirror-refresh(03:47,command) —agent-ctx mirrorfor the org's layer, so the next day's recall has current memory.
jobs/ is this deployment's config and is not packaged. Only nightly-sweep
and mirror-refresh ship as templates, in jobs.example/: copy them, replace
<org>, and point jobs_dir at the copies.
The contract it enforces
- Recall before the task — fail-soft; a missing mirror degrades to a marked "no memory available" line, never blocks the run.
- Journal after the task — the runner verifies one activity-log entry
carrying the run's
session:<id>token; a task session that did not journal itself gets a Draft entry backfilled by the runner and the run exits flagged. Jobs journal themselves per their own skills; a gap is logged, not backfilled.
Boundaries (from CONTRACTS.md)
- The worker never commits, tags, or pushes; it stops where
agent-ctxstops. - One organization per run: a run binds to one org's layer, mirror, and journal; nothing crosses orgs inside a run.
