A session the human is done with has to be removable, and removing it
must mean forgetting it: `DELETE /v1/sessions/{key}` drops the dispatch
record and the turn history recorded under the key, so re-creating the
same name starts an empty chat instead of replaying someone else's
turns. The route refuses a leased key, because the running turn's own
save would either fail against the missing record or write the session
back after the delete; the inventory is not narrowed to the
`interactive:` prefix, so a forge key deletes on the same route.
The history purge rides the hub, which owns the turn table, and it
lands after the record does: the purge is the one step this side cannot
undo, so it never runs for a delete the dispatcher refused. Purging on
the no-record path as well is what makes a half-done delete repairable
— a retried DELETE finishes the purge instead of 404-ing with the old
turns still under the key, where a session created again under the same
name would replay them.
The sessions routes stay off the agent token's allowlist in
tools/contract: the agent must not be able to delete the conversation
it is serving.
17 KiB
Rhema Claw
Still Work-In-Progress.
Rhema Claw is an autonomous personal agent ("claw"), built on the Rhema agent framework.
Features
The claw: rhema-claw
The resident agent. One daemon owns every session in one SQLite store:
yours (interactive:<name>, keyed per chat surface) and every watched
forge thread's. Talk to it over HTTP; it watches repos, answers forge
threads with coding-agent turns, and configures its own watches when
asked in chat (watch_repo/unwatch_repo/list_watches are tools the
model calls like any other).
rhema-claw # serve on 127.0.0.1:7799 (config: listen)
rhema-claw --once # one detect + one dispatch pass, then exit
claw mcp servers # the MCP meta surface (see rhema-mcp)
Coding jobs: the coding_agent tool
The claw is not the only agent it runs. The coding_agent tool drives
a full rhema-coding-agent session — its own shell, skills, and step
budget — in a named workspace, with one tool and an action argument:
addpins a name to an existing directory (rhema→~/rhema). The instance list is the audit surface for which checkouts the model can drive a coding agent in;dropremoves one, refused while a job is live.runenqueues a job. Async by design: a coding run is minutes of tool rounds, so the call answers with the job id in one model round and the turn runs off the chat — its own dispatcher,job:keys with forge-style leases, the same commit-before-execute discipline. The instance's coding session carries to the next run unlessfresh; the final text lands in the job row and the user's notifications.listnames the instances, each with its live job at a glance;statusreads jobs — every recent one, one instance's, or one job's result.- One live job per instance: two coding agents in one workspace is two agents editing the same files. Across instances, jobs run in parallel.
The bounded request view
A claw session never ends: the interactive chat is one conversation,
forge threads live as long as their pull requests, and a coding job's
session carries to its next run. From the start, the claw therefore
runs the transcript's bounded request view (rhema-transcript):
past a byte budget, older messages summarize into id+n|text lines
the agent's zoom tool opens to read any original back. The config
turns it off with transcript: false.
For the debug loop, daemon plus UI against throwaway state and zero
credentials: scripts/dev.sh --mock (canned streamed replies; the UI
is at localhost:3000, the claw at 127.0.0.1:7799). Drop --mock
and set the provider's key env var (CLAW_DEV_PROVIDER picks the
provider) to debug against a real model.
# ~/.config/rhema/claw.yaml
provider: anthropic
model: claude-sonnet-4-5
listen: 127.0.0.1:7799
token: change-me # optional; when set, /v1/* requires it
agent_token: change-me-too # the client tools' scoped bearer (rhema-tools)
tools_bin: ~/.local/share/rhema-tools/bin # put the client tools on PATH
poll_interval_secs: 120
max_turn_steps: 2048 # tool rounds one chat turn may run
watches: # a seed: imported while the table is empty
- forge: github
repo: acme/widgets
calendars: # feeds the claw reminds about
- name: home # the reminder session is calendar:home
kind: ics # an ics/webcal feed (Google's secret iCal
url: https://cloud.example.net/remote.php/dav/calendars/personal.ics
lead_minutes: [60, 15] # default [15]; horizon_hours default 24
- name: cloud
kind: caldav # a CalDAV collection (REPORT; Nextcloud,
url: https://cloud.example.net/remote.php/dav/calendars/me/personal/
username: me # Radicale); password falls back to
password: app-pass # CALDAV_PASSWORD; token: = bearer instead
- name: work
kind: google # the Google Calendar API
calendar_id: primary
api_key: pub-key # or token: (OAuth; env fallback CALENDAR_TOKEN)
max_turn_steps caps one turn's tool rounds (the coding driver's own
budget shape, and the same default, 2048): the claw is meant to work
unattended for a long time, so a cap this deep is a runaway guard, not
a work timer — a turn that hits it ends with a failed outcome and the
conversation intact; a follow-up message keeps going.
The surface refuses to start on a non-loopback listen with no
token: anything beyond loopback can reach the port, and the bearer
token is what stands between it and the agent's tools. Set a token, or
bind a loopback address. Resident sessions run the coding agent with
its approval seam auto-approved: tool calls, the shell included,
execute without a prompt. The daemon says so at startup, and the
bearer-gated surface plus the watch list are the trust boundary.
agent_token provisions the client tools: a second bearer the daemon
hands the agent's shell as RHEMA_TOKEN (with RHEMA_URL), valid
only for the data routes the tool contract allowlists — watches,
notifications, tasks — and only with the contract header at the
matching version. It cannot reach consents, sessions, ingest, or the
phones, so a model holding the token cannot prompt-inject its way past
the scoping (see specs/rhema-tools.md; the daemon runs the
contract's conformance suite against its own router at test time). It
must differ from token — the daemon refuses to start when the two
are the same. tools_bin layers a directory onto the shell PATH so
the provisioned tools resolve. With no agent_token, the daemon
provisions no tool environment: the agent's shell is exactly as it
was.
The native surface:
GET /healthz GET /v1/sessions
POST /v1/sessions {"name"} — create an empty interactive session
POST /v1/sessions/{key}/messages {"text": "..."} -> 202
GET /v1/sessions/{key}/events SSE turn events (see below); ?since=<seq> resumes a cursor
GET /v1/sessions/{key}/history one page of older turns: ?before=<seq>&limit=<n>
DELETE /v1/sessions/{key} drop a session and its turn history
GET /v1/watches POST /v1/watches DELETE /v1/watches/{forge}/{repo}
GET /v1/bootstrap {"soul": bool, "user": bool}
POST /v1/ingest {"source","key","payload"} -> 202
GET /v1/notifications?device=&since= POST /v1/notifications/{id}/ack
GET /v1/consents the asks a turn is waiting on
POST /v1/consents/{id} {"secret"} or {"grant": bool}
DELETE /v1/consents/{id} deny a pending ask
GET /v1/attachments/{id} an attached file's bytes
GET /v1/phone/commands?device= the device's queued, unconfirmed commands
POST /v1/phone/commands/{id}/result {"ok", "detail"?} — the device's report
GET /v1/providers the backend instances + the active pair
PUT /v1/providers/active {"provider","model"?} — switch, persisted
PUT /v1/providers/{id}/credentials add or replace an instance, persisted
DELETE /v1/providers/{id} remove one (the last is refused)
GET /v1/providers/{id}/models the live model listing
Identity and first-run
The claw's prompt is assembled per turn from an in-binary base plus
three identity files under the state dir's bootstrap/ (config:
bootstrap_root): SOUL.md, who the claw is, tone and purpose,
written with you; USER.md, the always-visible summary of who you
are; ENVIRONMENT.md, host, shell, and tool inventory, generated
deterministically at every boot (detection is code, not conversation;
hand edits are overwritten). The base renders the dispatch's own tool
roster too — every tool's name, description, and call schema, straight
from the resolver chain, so what the prompt lists is what the request
advertises. On first run the claw offers a short
setup in chat: who you are, what the machine is for, who it should be.
It writes the first two files itself through the bootstrap_save
tool as you answer, and — through the identity_set tool — sets its
own presentation the same way: its name, a mark or tagline, an avatar
from an image you hand it. The same tool is yours to invoke any time
("call yourself something else"); it is the same store the settings
pages write, so what it sets is what every client renders. Decline
and it simply keeps offering, never
interrogating. Both files stay hand-editable forever; /v1/bootstrap
lets a UI badge setup state.
SSE frames are id: <seq> plus data: {..}: user_message,
thinking_delta, text_delta, tool_start, tool_result,
turn_done. Every streamed event is persisted as it streams, so both a
reload and a daemon restart rebuild the transcript, and a session that
runs for weeks keeps all of it.
The seq is the record's dense per-key number for the event, and it is
the client's cursor. A subscription opens with a replay cut atomically
against the live feed, and the frames of both carry their seqs, so a
client that reconnects sends ?since=<last seq it saw> and receives
exactly the gap. With no cursor the replay is the tail — the newest 400
events, cut back to the first turn boundary in them — so a weeks-old
session paints its latest turn at once instead of replaying everything;
the turns above it come from the history route, one whole turn per
page, each ending exactly where the previous page began. A cursor the
record can no longer serve (a wiped table) answers with a reset;
comment and the tail, and the client rebuilds rather than splice.
A chat UI subscribes once, POSTs messages, and resets its mirror whenever the stream (re)opens, letting the replay rebuild it; the daemon owns the conversation, so the client mirrors the stream rather than managing history. The OpenAI shim and the message POST read the live feed only — replay never reaches a turn that is starting.
A message into a session whose turn is running steers it, the same
interrupt rhema-code's UI owns: the running turn is cut at its current
await (an in-flight shell command dies with it), closes on the stream
with turn_done carrying outcome: "steered", and the message runs as
the next turn of the same conversation. The messages route answers 202
the moment the message is accepted — the turn runs off the request, and
its reply arrives on the event stream — so a composer never locks for
the length of a turn and a follow-up is always typeable.
Every provider whose credentials resolve (env, the claw's own stored
logins, or the stored rhema config) is constructed at boot as a backend
instance named by an id, the kind unless a login named it, so two
llama.cpp servers (or two keys of one cloud) coexist under two names.
One is active, and switching with PUT /v1/providers/active is runtime
state persisted in SQLite: the
config's provider/model pair is only the seed, and when the config
names no provider the first credential discovery actually finds (a
stored login, an env key, or the stored rhema config) is active; the
keyless llama.cpp fallback never wins a seed, so the chart pins
nothing and a switch never needs a restart.
The claw registers RSD sources four ways: watches (forge), calendar
reminders (calendars: in the config; each tick polls every feed for
the occurrences starting within its horizon, and an upcoming event
warns once per lead_minutes on its persistent calendar:<name>
session, a turn whose final text auto-notifies the phone; recurring
series arrive expanded, overrides and cancellations applied, all-day
events stay silent), scheduled
tasks the agent creates in chat (schedule_task with at or
every_secs; a due task dispatches through the resident agent — into
the session the tool named, its own task:<id> slot by default — and
a reply on a chat-less key auto-notifies the phone; a task whose
session names a chat conversation wakes that session with its full
context, so "pick this back up at 10 am" or "before my meeting,
summarize X" is one tool call, with notify reaching the user away
from the chat), and a generic door,
POST /v1/ingest, that any external emitter addresses
(phone:<device>:<topic>
keys from the Android app, for example). The resident agent owns
interactive:, task:, phone:, and calendar: keys; forge threads
own the rest. Its notify tool queues a notification for a device;
the device polls and acks.
When a turn needs something only a human can give, it asks and waits
inside the tool call: ask_secret raises a consent request — a card in
the chat stream (consent_request over SSE, plus GET /v1/consents
for a UI catching up after a reload) — and the human answers through
POST /v1/consents/{id} ({"secret": ...}) or denies with DELETE.
The secret travels once, from the answer into the agent accounts; the
tool wakes with the account id, never the secret, and a denial or a
15-minute silence comes back in-band so the turn can say so and move
on. A watch with no token of its own polls — and replies — as the
matching saved account: the same forge, and on a self-hosted forge the
same base URL. Acting with a stored account is identity_exec: it runs one shell
command in the session workspace with the secret injected —
ACCOUNT_SECRET plus the service's own GITHUB_TOKEN/GITEA_TOKEN/
GITLAB_TOKEN, and a git credential helper — so git clone and API
calls work while the credential itself never reaches the transcript. A turn can also hand files over: attach_file copies a workspace
file (25 MiB cap, workspace-relative paths only) into the attachment
store beside the database and puts an attachment card on the stream;
the bytes are fetchable at GET /v1/attachments/{id} with the content
type the file's name implies, so a screenshot, a diff, or a report
reaches the chat as something to view or download rather than a
truncated snippet.
The phone is reachable too. Its status reports (/v1/ingest, source
phone, keys phone:<device>:<topic>) are state, not conversation:
the payload lands in the phone tables and stops there — no turn runs,
no push notification goes out per flip — so the phone tool's
status/devices actions read the latest battery, charging, network,
and DND observations without asking anyone. A bare phone:<device>
key is the device's resident dispatch key: a message posted to it runs
a turn like any resident key's. Acting on the phone —
dnd_on, dnd_off, alert — is consent-gated end to end: the tool
raises a consent card, the human approves or denies, and only then does
the command join GET /v1/phone/commands?device=, the poll list the
device executes from and reports on via POST /v1/phone/commands/{id}/result. The device confirms on its own clock
(the 15-minute WorkManager floor), so an answer may honestly be
"queued, not yet confirmed".
Stock OpenAI-speaking UIs point at the shim instead:
curl http://127.0.0.1:7799/v1/chat/completions -H 'Content-Type: application/json' -d '{
"model": "claw", "user": "web", "stream": true,
"messages": [{"role": "user", "content": "what are you watching?"}]
}'
The shim reads only the last user message (user picks the session,
default web), maps thinking deltas to reasoning_content, and
streams standard chat-completions chunks. There is one notion of
session, the daemon's, behind a wire format every web UI already speaks.
Deploying the claw
docker build -f deploy/docker/claw.Dockerfile -t rhema-claw .
helm install claw deploy/helm/rhema-claw \
--set token=$(openssl rand -hex 24) \
--set secret.stringData.ANTHROPIC_API_KEY=... \
--set secret.stringData.GITHUB_TOKEN=... \
--set config.provider=anthropic --set config.model=claude-sonnet-4-5
Expose it with the chart's optional ingress (ingress.enabled +
ingress.host, TLS strongly recommended, bearer tokens cross it) or
keep it cluster-private behind Tailscale; the claw is bearer-gated
either way. With the chart's UI on (ui.enabled, the default), the
same host serves both: the browser gets the web UI on /, the daemon
answers /v1 and /healthz; one address for the browser and the
phone app, no CORS anywhere.
The image is the daemon plus bash and git (the agent's shell is
load-bearing); the chart runs exactly one replica, the claw is
single-node by design: one SQLite store, one dispatcher. Single-node
is what makes restarts instant: at boot the daemon releases every
lease the previous process left — outright, not at the dead lease's
TTL — so a turn an update cut mid-flight answers its next message
instead of stalling behind a ghost (the committed tool call revives
at the top of that next dispatch). The
store and workspaces live on a PVC, claw.yaml in a ConfigMap (db and
workspace paths are pinned to the mounted volume), credentials in a
Secret, and the bearer token is the token value, required since the
daemon binds all interfaces in-cluster. persistence.enabled=false
trades restart survival for disposability.
The UI in web/claw is one client of the same surface: npm run dev
proxies to a local claw, npm run build emits the static bundle an
ingress serves next to /v1. The phone is another. android/ is the
Kotlin/Compose client: chat over the same SSE stream, phone status
(battery, charging, network, DND transitions) posted to /v1/ingest
as phone:<device>:<topic> events, and agent notifications polled
onto the lock screen (no push infrastructure; a 15-minute WorkManager
floor, live SSE while the app is open). See android/README.md.