Agentic case tracking, observation, and manual editing over messaging channels.
casey is a domain-configurable structured-intake agent -- anyone
messaging over WhatsApp/Discord, in their own language, is a reporter; casey
greets them warmly and quietly gathers a structured record (report/ticket
fields declared entirely by config -- see "Configuring casey" below)
without interrogating them, and gives the organising team one organised,
observable view per report. It amplifies the team's own way of working -- it
does not impose domain-specific rules or escalation; priority stays with the
people. Times are shown in SAST and phone numbers in +27 format by default
(overridable via CASEY_TZ/CASEY_COUNTRY_CODE).
This repo ships a generic IT/facilities-helpdesk demo config by default.
The animal-disease-surveillance-for-rural-South-Africa domain casey was
originally built for is a separate, fully self-contained config package:
AnEntrypoint/uhh (private). Install
it with git clone --recurse-submodules and run node ./bin/uhh.js up; it
declares casey as file:deps/casey, and an in-repo file: dependency cannot
be resolved inside an npx-installed package, so npx github:AnEntrypoint/uhh
does not work.
Point CASEY_CONFIG_DIR at a directory holding thatcher.config.yml,
report-fields.yml, and persona.cjs to swap casey's entire domain (report
vocabulary, agent persona, entity schema, dashboard labels) with no code
change. See AGENTS.md's "Configuration architecture" section for the full
schema, and AnEntrypoint/uhh's config/ directory for a real worked
example.
The three files are not resolved by one mechanism. src/config-loader.js
reads report-fields.yml and persona.cjs from CASEY_CONFIG_DIR, falling
back to this repo's bundled config/default/. thatcher.config.yml is
resolved separately -- by src/case-store.js's CaseStore constructor and
config-loader.js's readThatcherFieldEnum, each using CASEY_CONFIG_DIR
if set and otherwise <cwd>/thatcher.config.yml. config/default/ therefore
ships only two of the three files; the third is this repo's own root
thatcher.config.yml. Copying config/default/ as a template for a new
deployment gives an incomplete config dir -- copy the root
thatcher.config.yml alongside it.
casey is a thin orchestrator that composes four existing projects, each
checked out as a git submodule under deps/ for local editing -- see
"Composed AnEntrypoint dependencies" below and AGENTS.md's Architecture
section for the full mechanism:
| Layer | Project | Role in casey |
|---|---|---|
| Agent runtime | freddie |
A Cordis plugin tree (@freddie/cordis) whose real boot() assembles the running app: agent loop, tool registry, LLM seam, web server. It has no messaging-bot primitives of its own -- casey owns the WhatsApp/Discord transport (src/adapters/) and mounts it, plus its case_* tools, into freddie's tree as Cordis plugins under freddie-bundle/. |
| System of record | thatcher |
Config-driven CRUD + workflow + RBAC + audit. Holds case / event / contact and the case lifecycle state machine. |
| UI | anentrypoint-design |
webjsx + ripple-ui design system. Themes the observe + manual-edit dashboard. |
| LLM provider chain | acptoapi |
Model resolution, chain fallback, sampler backoff. |
WhatsApp / Discord / Sim
| message {from, text, raw{id}}
v
casey adapter -> casey handler -> find/create thatcher case
| | append event(inbound) [deduped by msg id]
| v
| agent turn (runTurn) with case context + case_* tools
| agent: create / update / transition / observe
| reply {to, text} | each action = an audited event row
+<------------------------------+ append event(outbound)
v
back to channel (nothing is sent if the model errors, times out, or returns empty --
the failure is logged loud and recorded, never a scripted reply)
thatcher data <- dashboard API (/api/cases ...) <- operator dashboard
observe timeline, edit fields, override transitions, reply on-channel
- Fully autonomous: the agent creates cases and drives workflow transitions itself, scoped by a per-case
autonomyofauto | assisted | observe. - Fully observable: every inbound/outbound/observation/action/transition is an append-only
eventrow. - Fully interactible: operators edit fields, force transitions, and reply to the contact from the dashboard; the agent picks up the new state on the next turn.
casey assumes the people on both ends may not be technical. That shapes two surfaces:
The person messaging in (WhatsApp/Discord). They may be elderly, may not read well, and may not speak English as a first language. So casey:
- replies in plain, short, warm language -- one idea per sentence, one question at a time, and never any internal jargon (case, triage, workflow, status, priority).
- mirrors their language: if they write in Spanish, it answers in Spanish.
- on first contact, greets them and gives their reference number in plain words, and sets the expectation that a real person will follow up.
- understands a few simple keywords in any phrasing or language and answers instantly,
without an LLM turn, where a fixed answer is better:
HELP(a short menu),STATUS(where their request stands, in plain words),HUMAN(hands off to a person -- flags the caseneeds-human, raises priority, and reassures them),STOP(opts them out; casey will not message again unless they ask forHELP/HUMAN). - never sends a blank or dead-end reply -- empty, emoji-only, and media-only messages still get a gentle, helpful answer.
- answers a greeting or chit-chat ("hi", "hello", "help") with a warm invitation to report, not the case-acknowledgement -- a turn that carries no domain-relevant content does not get "Thank you for letting us know ... your reference is X"; the moment the contact states a real fact, casey switches to gathering the report as usual.
The operator watching the dashboard. They may not understand workflow jargon either. So:
- a "Needs you now" inbox is pinned to the top of the list. It is a guided queue of only the cases that need a person right now (someone asked for a human, a case casey will not answer on its own, a request stuck waiting over a day), each shown with the plain reason it is there ("This person asked to talk to a real person.") and ranked by urgency, so the operator never has to hunt. When nothing needs a person it shows a calm "All caught up" message, not a blank box.
- a one-time plain-words help overlay (re-openable with the
?button) explains, with no jargon, what each row is, what the amber dot means, and what every button does. - a plain-language mode (remembered across visits) relabels stages to
friendly names (
Looking into it,Working on it,Done, ...) everywhere. - each open case shows a "what to do now" line derived from its state (e.g. "This person asked for a real person. Reply to them below."), plus ready-made replies the operator can tap to fill the reply box (then edit before sending) -- no blank-page problem.
- if the person wrote in another language, the reply box warns the operator to answer in their language, and the ready-made replies are not offered for someone who asked to stop.
- when someone asks for a human, a loud red banner (with a soft chime and a flashing browser tab) appears once for that case so an idle operator notices; opening the case clears it.
- when the operator moves a case to a new stage, casey can send the person a short plain-language note ("Good news. Someone is working on your request now.") so they are kept informed without having to ask. Internal stages stay silent, and a person who opted out is never messaged.
Anyone messaging in defaults to the reporter tier: casual, public, report-only.
An operator can promote a trusted reporter (the Reporters panel, or the casey operators CLI break-glass path) to field_worker, which additionally unlocks
agentic case-query tools (their own open cases, "near me" lookups, place
enquiries) and casual location check-ins so they show up on the operator map for
direction/dispatch. The tier is never agent-settable -- no case_* tool touches
it, so nothing a contact says in conversation can promote themselves.
You do not need to be a developer to run casey day-to-day:
npm install
node bin/casey.js init # writes a .env you fill in (channel tokens, session secret)
node bin/casey.js doctor # green/red preflight: deps, channels, port, config -- fix the reds
node bin/casey.js up # starts the gateway + dashboard, prints the dashboard URLThen open the dashboard URL it printed (default http://localhost:4000). casey init and
casey doctor exist so the first run tells you exactly what is and isn't ready before you start;
doctor flags partial WhatsApp credentials, a missing thatcher.config.yml, an unusable
ACPTOAPI_CHAIN_LINK_TIMEOUT_MS, dirty or off-main submodules and a held port instead of
failing silently. The dashboard uses per-operator login, not a shared token; a fresh deployment
auto-creates one admin account on first boot and prints its password once.
casey needs at least one real channel (Discord or WhatsApp) configured in .env before casey up
will start -- there is no offline demo mode.
Note for developers: a bare npm install at this repo's root crashes once the
node_modules/@freddie/* junctions exist (an @npmcli/arborist tree-load
failure across the 220 junctions). Run node scripts/install-freddie-deps.mjs
and node scripts/link-deps.mjs directly instead -- see AGENTS.md's Dev
workflow.
The dashboard is the whole operator surface -- one page, no build step:
- "Needs you now" inbox (top of the list): a ranked, plain-worded queue of just the cases that
need a person now -- someone asked for a human, a case casey will not auto-answer, or a request stuck
waiting over a day. Each row leads with the reason; opting-out contacts are never listed. It reads
"All caught up" when there is nothing to do. The Focus button (or a
#inboxlink) collapses the page to just this ranked list and lightens background polling -- a phone-friendly, single-column triage view; tap a row to open it. - Case list (left): every case, with a priority badge, last-activity time, and an amber dot on
cases that need a human (autonomy
observe/assisted, or someone who asked for a person). A live search box (press/) filters by ref/subject/summary/contact, and a stage dropdown filters by workflow status.j/kmove the selection,Enteropens,Escclears. - Detail (right): edit subject/summary/priority/tags/assignee/autonomy (with an inline
explainer of what each autonomy mode does) and Save. Override the workflow stage with an
optional reason. Reply to the contact on their channel as a human (
Ctrl/Cmd+Enterto send), with ready-made replies you can tap to start from and a warning to answer in the contact's language when they did not write in English; the toast tells you whether it was delivered or only logged, and whether the stage change sent the person a note. - Handoff alert: when a contact asks for a real person, a loud banner (chime + flashing tab) fires once for that case so an idle operator notices; opening the case clears it.
- Team workload (
Teambutton): a worst-first, aggregate-only view of who is holding what -- per operator: open cases assigned, claims sitting too long, replies sent today, usual first-reply speed, and the oldest case still waiting. A card per rostered operator (the liveoperator_accounttable, managed from the dashboard) even at zero load, so management sees overload and dropped claims at a glance without opening a case; no per-contact rows. - Map view: every case with an agent-estimated or GPS
lat/lonplotted on a Leaflet+OSM map, status-colored and clustered, with a correlated-cases overlay, an operator-coverage overlay (each operator's learned working area), and a field-worker location overlay (fromcase_checkinself-reports). A case with no coordinate lands in an "unresolved" bucket instead of being dropped. - Secretary queue:
/api/secretary/queuegroups the worst-first attention list by normalised place and by assignee, so a follow-up owner sees which reports have been dropped and where. - Reporters panel: promotes a trusted reporter to the
field_workeraccess tier (unlocking their own case-query tools and casual location check-ins) or demotes them back toreporter. Operator-only and never agent-settable -- see "Reporter access tiers" above. - Mine filter (
Minebutton): once you have picked who you are (top-right),Minescopes both the case list and the "Needs you now" inbox to just the cases you have claimed, so a busy shift can work its own queue. - Keyboard triage:
j/kmove the selection,o/Enteropens the top case,cclaims the open case as yours,ejumps to the reply box,/focuses search,?toggles help,Escsteps back. - Timeline: every inbound/outbound/note/action/transition/observation as an append-only row, colour-coded by kind, with relative timestamps (hover for the absolute time).
- Plain-language help + first-run onboarding: a focused three-step quick-start overlay greets a
first-time operator (pick who you are; the inbox is your queue; claim before you reply) and is
remembered once dismissed (re-open from help). A separate help overlay (
?) explains everything including the keyboard shortcuts; a plain-mode toggle relabels stages to friendly names everywhere (remembered), and each open case shows a "what to do now" hint derived from its state. - Non-blocking toasts replace alert popups, a banner appears if the connection drops, the case list polls every 5s while it is the visible surface (paused while you're typing so it never clobbers an edit, and stood down on the map home view and in Focus mode), new cases raise a toast, the open case is deep-linked in the URL (shareable), and a light/dark toggle persists. All contact-supplied text is HTML-escaped before render.
node bin/casey.js init # scaffold a .env
node bin/casey.js doctor # preflight: what's ready, what's missing
node bin/casey.js up # gateway (any channel with creds) + dashboard on :4000
node bin/casey.js dashboard # observe/edit dashboard only, on :4000
node bin/casey.js cases # list cases (empty -> hint on how to make one)
node bin/casey.js show <ref|id> # show a case + full timeline
node bin/casey.js attention # worst-first attention ranking
node bin/casey.js handover # shift-handover summary
node bin/casey.js report # management report (SLA, response + closure rates) over --days N, default 30
node bin/casey.js health # read-only guardrail summary (writes nothing)
node bin/casey.js sweep # run the health-guardrail sweep once now (writes tags/observations)
node bin/casey.js transition <ref|id> <stage> [--reason] # legality-checked stage move
node bin/casey.js erase-contact <contact|ref> --yes [--reason] # irreversibly scrub a contact's PII
node bin/casey.js operators <add|list|disable|enable> ... # dashboard login accounts (break-glass)
node bin/casey.js --version # print the version (also --help / -h on any command)
npm run lint # dependency-free preflight; the gate to run before pushing
npm run gui-check # drives the real dashboard in headless Chromium (needs a browser)
npm run scan-deps # supply-chain scan of own source + node_modules
npm run check-submodules # branch/dirty/ahead-behind report on every deps/* checkoutnpm run lint (node scripts/lint.mjs) runs every check that works from a bare
clone: node --check on all JS, a YAML parse of thatcher.config.yml,
package.json sanity, the ASCII-only source convention, and the structural
grep gates -- pure-llm, no-stub-mock, pii-safety, trust-boundary,
cli-help, plus design-lint when deps/design happens to be checked out. It
needs no sibling checkouts. There is no CI workflow in this repo -- .github/
does not exist -- so npm run lint is a local gate a human or agent runs by
hand before pushing. There is no automated test suite either; verification is
manual/live against a real running casey up instance.
casey up runs the real model through acptoapi's provider chain. Put your provider
key in ~/.acptoapi/.env (acptoapi loads that file itself, not casey's .env) and
set CASEY_LLM_MODEL if you want something other than the claude/sonnet default.
If the model errors, times out, or returns nothing, casey sends
NOTHING to the contact -- no scripted apology -- and records the failure loudly as an
observation for an operator to see.
casey up runs the gateway+dashboard under a supervisor that forks them in a child
worker and recycles it on crash or on a source edit, so a code change reloads
without a manual restart and a crash restarts on its own (the parent never imports
app code). Source under src/ and a sibling ../freddie/src is watched by default;
add more dirs with CASEY_RELOAD_PATHS. Use casey up --no-reload to stop watching
and casey up --no-supervise to run in-process without restart-on-crash. See
AGENTS.md "Supervised runtime" for the full env-var set.
| Variable | Purpose |
|---|---|
DISCORD_BOT_TOKEN |
Enable Discord (real bot, gateway WebSocket receive with RESUME). |
WHATSAPP_API_TOKEN, WHATSAPP_PHONE_NUMBER_ID |
Enable WhatsApp (Meta Graph send). |
WHATSAPP_VERIFY_TOKEN |
Webhook verification handshake token. |
WHATSAPP_APP_SECRET |
When set, inbound webhooks are HMAC-SHA256 verified (X-Hub-Signature-256); forged posts are rejected. Required, not optional, once WhatsApp credentials exist. |
WHATSAPP_WEBHOOK_PATH |
Path Meta POSTs to (default /webhooks/whatsapp). There is no WHATSAPP_WEBHOOK_PORT. |
CASEY_WEBHOOK_HOST, CASEY_WEBHOOK_PORT |
Host/port of the freddie-tree web server carrying that webhook (default 127.0.0.1:4001) -- a different socket from the dashboard's 4000. This is the port a WhatsApp deployment publishes to Meta as its callback URL, so Meta needs a stable public URL for it; use a tunnel in dev. |
CASEY_SESSION_SECRET |
HMAC key signing the dashboard session cookie. The dashboard uses per-operator username/password login (no bearer token, no ?token=); a fresh deployment with zero accounts auto-creates one admin with a random printed password. Random per process when unset, so a restart logs everyone out -- set it explicitly for sessions to survive a restart. |
CASEY_COOKIE_SECURE=0 |
Drop the Secure flag on the session cookie for a plain-HTTP dev/LAN deployment (Secure is on by default). |
CASEY_TRANSCRIBE_VOICE_NOTES=1 |
Opt-in: transcribe an inbound voice note and fold the text into the case (needs OPENAI_API_KEY). Off by default (external data egress). |
CASEY_DESCRIBE_PHOTOS=1 |
Opt-in: describe an inbound photo (visible detail relevant to the active domain's report fields) into the case (needs OPENAI_API_KEY or ANTHROPIC_API_KEY). Off by default (external data egress). |
CASEY_VOICE_REPLIES=1 |
Opt-in: speak the reply back as a voice note so a reporter who cannot read still hears it (needs OPENAI_API_KEY or ELEVENLABS_API_KEY). Additive to the text, fail-open, off by default (external data egress). |
CASEY_LOG=silent |
Silence casey's structured JSON logs. |
CASEY_RELOAD=0 |
Disable hot-reload (crash-restart stays on). |
CASEY_RELOAD_PATHS |
Comma-separated extra dirs to watch for reload (default src/ + ../freddie/src). |
CASEY_RECEIVE_SILENCE_MS |
Restart a channel that went silent this long (zombie-receive self-heal; default 0 = off). |
CASEY_OPERATORS (a comma-separated id:Name roster env var) has been removed --
the team-coverage-gap check reads the live operator_account table directly,
the same roster the dashboard's Team panel and Reporters panel already show.
Setting it has no effect.
casey/
thatcher.config.yml entities (case/event/contact) + case workflow (system of record)
config/default/ bundled demo config: report-fields.yml + persona.cjs
bin/casey.js CLI entry; bin/casey-cli.mjs holds the COMMANDS table (colorized, --help/--version)
freddie-bundle/ casey's Cordis plugins mounted into freddie's real boot(): case-tools, llm-acptoapi, platform (WhatsApp/Discord wiring), tool-allowlist
src/
casey.js top-level assembly: store + adapters + freddie boot + gateway shim + logger
adapters/ casey-owned WhatsApp/Discord transport
agent/run-turn.js runTurn() adapter driving freddie's real Agent
config-loader.js resolves CASEY_CONFIG_DIR (or config/default/): report-fields.yml + persona.cjs
store/report-shape.js derives REPORT_KEYS/CRITICAL_FIELDS/etc from the loaded config
case-store.js thatcher wrapper: find-or-create (locked), events, transitions, paging, config validation
case-runtime.js process singleton so the plugin reaches the live CaseStore
case-tools.js composes the 18 case_* tools (report/get/list/update/observe/transition/mine/today/new/switch/split/checkin/idle/health/stop/handoff/link_suggestions/transitions_available) from the case-tools-*.js modules, autonomy- and tier-enforced
hooks/handler.js makeCaseHandler: case-aware inbound (agent-driven, no deterministic text processing), dedup, media, observe -- re-exported by gateway-hooks.js
provenance-wire.js additive provenance-tagged Observation write alongside the real thatcher case.report write
dashboard/server.js express API + anentrypoint-design-styled SPA (observe + edit + override + reply + map + reporters + accounts)
See AGENTS.md for the full source map, every composed project's exact role, and
the complete environment-variable reference (this README covers only the common
subset above).
All four are checked out as real git submodules under deps/ for local
editing. Three of them -- thatcher, acptoapi, anentrypoint-design --
are declared in package.json as file:deps/<name> npm dependencies, so
npm install resolves each straight from its own already-checked-out
submodule: no GitHub fetch, no registry. Editing deps/<name> in place and
re-running npm install is enough to pick the change up locally; a push to
that project's own main is still required before any other clone sees it.
freddie is not declared in package.json at all. It is a pnpm workspace
of ~220 @freddie/* packages whose workspace:^ cross-deps plain npm cannot
resolve; scripts/install-freddie-deps.mjs runs pnpm install inside
deps/freddie and scripts/link-deps.mjs symlinks each package into
node_modules/@freddie/. Both are wired into postinstall, and a fresh clone
needs pnpm on the machine.
npm run check-submodules (also part of casey doctor) reports each
submodule's branch/dirty/ahead-behind state. See AGENTS.md's Architecture
and Dev workflow sections for the full mechanism and a copy-pasteable
edit/push/bump sequence.