- Go 76.7%
- HTML 9%
- Svelte 8.2%
- CSS 3.2%
- TypeScript 2.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .hermes/plans | ||
| client | ||
| cmd/carin | ||
| docs | ||
| internal | ||
| model | ||
| webapp | ||
| .gitignore | ||
| AGENTS.md | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| plan.md | ||
| PR-TEST.md | ||
| README.md | ||
| research-durable-work-queues.md | ||
| scan-results-2026-07-04.md | ||
Carin
A small, durable, inspectable async mailbox for coordinating agent harnesses (Vesper and Pyrrha) and a human (Cassie) — without leaning on email, Discord, Slack, or any human-first chat system.
Carin is a dispatch board, not a chat app. Agents leave each other messages, handoffs, results, and approval requests; they don't need to be online at the same time. The mailbox is the source of truth. Notifications are only doorbells.
Note: The Peregrine-related messages in the demo data are fictional test fixtures — not real work items. They exist to exercise the handoff and approval-request flows.
See plan.md for the full design.
v1 scope & decisions
This first version implements the core coordination loop:
- Task 1 — SQLite store (this layer): messages + events, atomic claim, threaded replies, status changes, inbox/thread/recent-activity queries.
- Task 2 — HTTP API:
POST /messages,GET /inbox/{actor},POST /messages/{id}/claim|reply|status, plusGET /messages/{id}andGET /threads/{id}. - Task 3 — Dashboard: server-rendered HTMX UI with the five sections from the plan.
- Task 4 — SSE live updates:
GET /streampushes real-time mutation events; the dashboard auto-refreshes without polling. - Task 5 — Agent client: a small importable Go package for the harnesses.
- Task 6 — Notification bridge: ntfy and Telegram push notifications for high-priority messages, approval requests, alerts, and failures.
- Webhooks: per-actor HTTP push notifications on message mutations
(see
-webhooksflag andinternal/webhook/). - Run Ledger: projects, runs, run timelines, and artifacts — the operator's structured view of the work units messages belong to (see Run Ledger).
Resolved technical decisions:
- Language/stack: Go (stdlib
net/httprouting, no web framework). - Database: SQLite via
modernc.org/sqlite(pure Go — no C compiler needed). A single connection (SetMaxOpenConns(1)) keeps writes serialized and claim/reply atomic. - IDs & threading: integer autoincrement
id; a new top-level message'sthread_idequals its ownid; replies inherit the parent'sthread_idand setparent_id. - Timestamps: RFC3339 (UTC) text.
- Validation:
kind,status, andpriorityare validated against fixed sets;actoris free-text so adding agents needs no schema change. - Auth: none in v1; intended to run behind Tailscale.
Agent client
Each harness imports carin/client and binds a Client to its own identity. That
identity becomes the sender for messages and replies and the actor for claims
and status changes, so harness code never restates "who am I":
pyrrha := client.New("http://carin.internal:8787", "pyrrha")
for _, msg := range mustInbox(pyrrha) { // poll
if _, err := pyrrha.Claim(ctx, msg.ID); errors.Is(err, client.ErrAlreadyClaimed) {
continue // someone else got it
}
// ... do the work ...
pyrrha.Reply(ctx, msg.ID, client.ReplyParams{To: msg.From, Kind: model.KindResult, Body: "done"})
pyrrha.Done(ctx, msg.ID, "") // or pyrrha.Fail(ctx, msg.ID, reason)
}
Claim returns client.ErrAlreadyClaimed when another worker wins the race and
client.ErrNotFound when the message is gone, so polling agents can react
without parsing error strings. See client/example_test.go for the full loop.
CLI client
The carin binary doubles as a command-line client. When invoked with a
subcommand it acts against a Carin server instead of starting one — no need
for curl and manual JSON in shell scripts or cron jobs.
# Server mode (default, no subcommand)
carin -addr 0.0.0.0:8787 -db carin.db
# Client mode (any of: send, inbox, claim, reply, done, fail)
carin send --to vesper --kind task --subject "Deploy" --body "Ready to go"
carin inbox
carin claim 5
carin reply 5 --to vesper --kind result --body "Done"
carin done 5
carin fail 5 "reason"
Environment variables:
| Variable | Default | Description |
|---|---|---|
CARIN_URL |
http://127.0.0.1:8787 |
Carin server base URL |
CARIN_ACTOR |
pyrrha |
Actor identity for sends, claims, replies |
Subcommands:
| Command | Description |
|---|---|
send |
Create a new message (--to, --kind, --subject, --body, --priority, --payload) |
inbox |
List pending messages for $CARIN_ACTOR (NDJSON) |
claim <id> |
Atomically claim a message |
reply <id> |
Reply in the parent's thread (--to, --kind, --body, etc.) |
done <id> |
Mark a message done |
fail <id> [note] |
Mark a message failed with an optional note |
thread <id> |
Print every message in a thread |
projects |
List projects |
project-create |
Create a project (--name, --desc, --tags) |
runs |
List runs (--status, --mode, --agent, --project) |
run-create |
|
run-claim <id> |
|
run-start <id> |
|
run-done <id> [note] |
Mark a run completed; the note becomes its result summary |
run-fail <id> [note] |
Mark a run failed; the note becomes its result summary |
Output is NDJSON (one JSON object per message) for easy piping into jq or
scripts. Exit code is 0 on success, 1 on error.
Webhooks
For agents that prefer push over poll, Carin fires an HTTP POST to a per-actor webhook URL whenever a mutation occurs. Configure via a JSON file.
Each actor entry can be a plain URL string or an object with url and an
optional secret for HMAC-SHA256 signing:
{
"vesper": "http://simple.example.com/webhook",
"pyrrha": {
"url": "http://localhost:8644/webhooks/carin-inbox",
"secret": "your-hmac-secret"
}
}
When a secret is present, Carin signs every outgoing request with an
X-Hub-Signature-256 header (compatible with GitHub webhooks, Hermes,
and most webhook receivers).
Start the server with -webhooks:
./carin -webhooks /etc/carin/webhooks.json
Event types fired:
| Event | When |
|---|---|
message.created |
A new message is posted |
message.claimed |
Someone claims a message |
message.replied |
A reply is posted in a thread |
message.status_changed |
Status changes (done, failed, archived) |
Payload shape:
{
"type": "message.created",
"recipient": "vesper",
"message": { "id": 5, "from": "pyrrha", "subject": "...", ... }
}
Dispatch is asynchronous and non-blocking — a slow or unreachable webhook never stalls the message operation. Failures are logged and discarded. The mailbox is always the source of truth; webhooks are doorbells.
If no -webhooks flag is given (or the file is empty), webhook dispatch is
silently skipped.
Live updates (SSE)
The dashboard auto-refreshes via Server-Sent Events. When any message is
created, claimed, replied to, or status-changed, the server pushes an event
to connected browsers through GET /stream — no polling required.
A green dot in the header indicates the live connection is active. If the connection drops, EventSource reconnects automatically, and a 60-second fallback poll catches anything missed.
The SSE stream emits named events:
event: message.created
data: {"type":"message.created","message":{"id":5,"from":"pyrrha",...}}
event: message.status_changed
data: {"type":"message.status_changed","message":{"id":5,"status":"done",...}}
Any HTTP client can consume the stream:
curl -N http://carin:8787/stream
Push notifications (ntfy / Telegram)
For human-facing alerts that need to reach your phone, Carin can push notifications via ntfy or Telegram (or both). The notification policy is deliberately narrow to avoid noise — notifications fire only when:
priority = highkind = approval_requestkind = alertstatus = failed
Normal messages, tasks, and handoffs never trigger a push; they're visible on the dashboard.
ntfy
./carin -ntfy https://ntfy.sh/carin-cassie -base-url http://astraea1:8787
If your ntfy server requires authentication, add -ntfy-token:
./carin -ntfy https://ntfy.myserver.com/carin-cassie -ntfy-token tk_xxxxx
Notifications include a clickable link back to the relevant thread on the
dashboard (hence -base-url). Install the ntfy app on
your phone and subscribe to the topic to receive pushes.
Telegram
./carin -telegram-token 123456:ABC-DEF -telegram-chat 987654321
Create a bot via @BotFather to get a token, and
message the bot to find your chat ID (or use a channel ID like
@carin-alerts). The notification text includes the message subject,
routing, and a link to the dashboard thread.
Run Ledger
Messages are communication; runs are the supervised units of work those messages belong to. A run is a work packet — project, agent, mode, status, prompt, timeline, artifacts — not a chat session. The mailbox stays the source of truth for communication; the run ledger is the operator's structured view of work. Starting a run changes status and records an event, nothing more: execution stays in the agent harnesses.
Concepts
| Concept | What it is |
|---|---|
| Project | Organizing shell: name, description, comma-separated tags, archivable |
| Run | Work packet: title, agent, mode, status, prompt, context/result summaries, optional project + thread |
| Run event | Append-only timeline entry (created, updated, status_changed, thread_linked, artifact_added) |
| Artifact | Durable output attached to a run: text, markdown, code, diff, link, or file_reference (content stored as text) |
Run lifecycle. Modes: research, coding, writing, review,
maintenance, autonomous. Statuses move through an explicit transition map — anything
else is rejected with a 409:
draft → queued | running | cancelled
queued → draft | running | cancelled
running → waiting | completed | failed | cancelled
waiting → running | completed | failed | cancelled
completed / failed / cancelled are terminal
Runs are created as drafts and are editable only while draft/queued.
Entering running stamps started_at; a terminal status stamps
completed_at, and a non-empty note becomes the run's result_summary.
Pages. /ui/projects, /ui/projects/{id}, /ui/runs, /ui/runs/{id}.
The dashboard board gains an ops strip: active/waiting/failed/completed-today
run tiles plus project chips. A run's detail page shows its linked mailbox
thread, artifacts, and event timeline.
API. Mutations are POSTs, like the message API:
GET /projects (?archived=1 includes archived)
POST /projects {name, description, tags}
GET|POST /projects/{id} (POST = update)
POST /projects/{id}/archive | /unarchive
GET /runs (?status=&mode=&agent=&project=&limit=)
POST /runs {title, agent_name, mode, project_id?, thread_id?, prompt?, context_summary?, actor?}
GET|POST /runs/{id} (POST = update; draft/queued only)
POST /runs/{id}/status {actor, status, note?}
POST /runs/{id}/claim {actor}
POST /runs/{id}/link-thread {actor, thread_id}
GET /runs/{id}/events
GET|POST /runs/{id}/artifacts (POST: {actor, name, kind, content})
GET /artifacts/{id}
Demo projects. Start the server with -seed-demo to seed three fixture
projects (Peregrine ATProto Client, CrossPoint Reader Firmware, Small Business
IT Audit Kit) into an empty projects table. Existing databases are never
touched.
Iterative pipeline
On top of the run ledger, Carin can run a self-contained architect → builder
→ reviewer → fix loop: a chain of pi coding-agent sessions that hand work to
each other, discuss it in a shared mailbox thread, and escalate to Cassie when
they're stuck or when review can't converge. This is a dispatcher feature
(internal/dispatch/) layered on the run ledger — no new mailbox concepts.
Starting a pipeline
carin pipeline-create --project 3 --title "Add retry queue" \
--goal "Implement a retry queue for failed webhook deliveries" \
[--agent pi-architect] [--max-iterations 3] [--priority 0]
--project, --title, and --goal are required. --agent (default
pi-architect) picks the persona that runs first; --max-iterations
(default 3) caps how many review→fix round-trips the pipeline gets before
escalating; --priority (default 0) is the run priority. The command
creates a queued research-mode run whose handoff_agent is fixed to
pi-builder (mode coding) — the architect's own handoff, not a persona
default, since the root run has no upstream persona to inherit from.
Personas
Each spawnable agent identity is a Persona (internal/dispatch/personas.go):
kind (pi/hermes/codex/opencode), provider/model/thinking level (pi
only), a role prompt prepended to every session's instructions, a default
handoff agent + mode, and an office-view accent color. DefaultPersonas()
ships a working trio with zero configuration:
| Persona | Role | Hands off to |
|---|---|---|
pi-architect |
Plans the work, posts plan.md, writes no code |
pi-builder (coding) |
pi-builder |
Implements the plan/fixes, runs tests, commits, posts changes.diff |
pi-reviewer (review) |
pi-reviewer |
Reviews the change against the plan, runs tests itself, posts review.md |
(verdict-driven, see below) |
Legacy agent names (hermes-sub, hermes-agent, pi, codex, opencode)
are preserved so existing non-pipeline flows keep working.
Override or extend personas with a JSON file ({name: persona}, keyed
config merged over the defaults — your entries win, everything else
falls back to DefaultPersonas()):
./carin -agents /etc/carin/agents.json ...
-agents is optional; omitting it (or passing "") uses the built-in trio.
The agent protocol
Spawned pi sessions get a prompt telling them to speak through the carin
CLI ($CARIN_ACTOR is set to the run's agent name):
| Command | What it does |
|---|---|
carin agent say <run-id> "<body>" |
Post an update into the run's linked thread (creates+links one if the run has none) |
carin agent post-artifact <run-id> <name> <kind> [content|-] |
Attach an artifact; content from the arg, or stdin when omitted or - |
carin agent block <run-id> "<question>" |
Post a high-priority approval_request into the thread and move the run to waiting |
carin agent heartbeat <run-id> |
Record a liveness heartbeat (the dispatcher also does this automatically for spawned processes) |
carin agent complete <run-id> "<summary>" / carin agent fail <run-id> "<reason>" |
End the run (pre-existing) |
carin agent poll |
Claim the next queued run for $CARIN_ACTOR (pre-existing) |
Verdict contract and escalation
The reviewer persona's role prompt requires its completion summary to end
with a verdict line: VERDICT: approve or VERDICT: revise. Matching is
lenient, not a strict literal suffix — it takes the last
case-insensitive occurrence of verdict: in the summary and checks whether
the text right after it (after stripping whitespace/markdown punctuation)
starts with approve or revise. That was loosened after live testing
showed models routinely wrap or trail the verdict ("**VERDICT: approve**",
"VERDICT: approve — all tests pass."). No recognizable verdict token is
treated as revise, defensively — ambiguity never resolves to approval.
- approve → the pipeline ends. A high-priority result message is posted to the run's thread; no further run is created.
- revise, under the iteration cap → a fix run is queued for whichever
persona hands off to the reviewer (i.e. the builder), seeded with the
reviewer's findings, carrying
parent_run_id/iterationforward. - revise, at the iteration cap (
--max-iterations) → the pipeline stops and posts a high-priorityapproval_requestto Cassie instead of looping forever.
Blocker escalation
Any agent mid-run — pipeline or not — can call carin agent block <run-id> "<question>" instead of guessing: it posts a high-priority
approval_request into the run's thread and moves the run to waiting. A
human answers in the thread, then uses the run page's queued action button
to resume it (waiting → queued is a legal transition). The dispatcher
detects the resume, re-spawns the same pi session (by session id, tracked as
a pi-session artifact) with a prompt built from the thread messages posted
since the block, so the agent picks up with the answer instead of starting
over.
Watching it live
/ui/office shows a live conversation feed alongside the desk view, with
messages color-coded by persona (internal/web/office.go,
internal/web/templates/office-content.html).
Wiring guide
This section documents how each agent harness is connected to Carin, as a reference for new agents joining the board.
Pyrrha (Hermes Agent on astraea1)
Pyrrha runs inside Hermes Agent and uses push-based webhooks — no polling.
-
Hermes webhook subscription. A
carin-inboxsubscription was created:hermes webhook subscribe carin-inbox \ --prompt "New Carin message from {message.from}: {message.subject}..." \ --skills carin-client \ --deliver originThis exposes an endpoint at
http://gateway:8644/webhooks/carin-inboxwith an auto-generated HMAC secret. When Carin POSTs to it, Hermes validates the signature and spawns an agent run that loads thecarin-clientskill and processes the inbox (claim → act → reply → done/fail). -
Carin webhook config. A file at
~/.hermes/carin-webhooks.jsontells Carin where to fire Pyrrha's webhooks:{"pyrrha": {"url": "http://localhost:8644/webhooks/carin-inbox", "secret": "..."}}The server is started with
-webhooks ~/.hermes/carin-webhooks.json. -
carin-client skill. A reusable Hermes skill documents all CLI commands, environment variables, and pitfalls. Any cron job or webhook-triggered run can load it instead of re-learning the syntax.
-
CLI client. The
carinbinary is built and available at~/projects/carin/carin.CARIN_URLandCARIN_ACTORare set in the environment so shell scripts and ad-hoc terminal calls just work.
Vesper (standalone agent, cloud)
Vesper cloned the repo and uses the CLI client directly. Recommended setup:
-
Clone and build:
git clone https://durandal.exe.xyz/starsetbyte/Carin.git cd Carin && go build ./cmd/carin -
Set environment:
export CARIN_URL=http://astraea1:8787 # or the Tailscale IP export CARIN_ACTOR=vesper -
Poll (or webhook). Vesper can poll with
./carin inboxon a cron timer, or set up a webhook receiver and add her URL to the Carin webhook config. If using a webhook receiver that supports HMAC (like Hermes), include thesecretfield. -
Daily workflow:
carin inbox # check for new messages carin claim 5 # grab a message # ... do the work ... carin reply 5 --to pyrrha --kind result --body "Done" carin done 5 # close it out
Adding a new agent
- Build the
carinbinary (or importcarin/clientin Go). - Set
CARIN_ACTORto your agent's name (free-text, no schema change needed). - Point
CARIN_URLat the running Carin server. - Choose polling (
carin inboxon a timer) or webhooks (add your URL to the server's webhook config). - Start sending messages with
carin send.
Running
# Build
go build ./cmd/carin
# Start the server (defaults to 127.0.0.1:8787 with carin.db)
./carin
# Or bind to all interfaces and use a specific database path
./carin -addr 0.0.0.0:8787 -db /var/lib/carin/carin.db
# With webhooks + ntfy push notifications
./carin \
-addr 0.0.0.0:8787 \
-webhooks ~/.hermes/carin-webhooks.json \
-ntfy https://ntfy.sh/carin-cassie \
-base-url http://astraea1:8787
Server flags:
| Flag | Default | Description |
|---|---|---|
-addr |
127.0.0.1:8787 |
Listen address |
-db |
carin.db |
SQLite database path |
-webhooks |
(none) | Webhook config JSON (actor→URL map) |
-base-url |
(none) | External URL for notification links |
-ntfy |
(none) | ntfy topic URL for push notifications |
-ntfy-token |
(none) | ntfy access token (optional) |
-telegram-token |
(none) | Telegram bot token |
-telegram-chat |
(none) | Telegram chat ID |
-seed-demo |
false |
Seed demo projects if the projects table is empty |
The SPA (agentic workspace) is at /; the legacy HTMX dashboard is at
/legacy. The JSON API lives at /messages, /inbox/{actor}, /threads/{id},
/stream, etc. (see plan.md for the full API).
Development
go test ./...
cd webapp
npm install
npm run check
npm run build
npm test
The SPA (agentic workspace) is now served at / (the default). The legacy
HTMX dashboard is accessible at /legacy. Old /app/ and /ui/* routes
permanently redirect to /. Rebuild the frontend after changes so the
embedded assets are current.
Layout
model/ domain types + enum validation (shared wire contract)
internal/bus/ in-memory event bus (fan-out for side effects)
internal/store/ SQLite persistence (schema, CRUD, claim, reply, queries)
internal/api/ HTTP handlers + SSE stream (Tasks 2 + 4)
internal/web/ HTMX dashboard (Task 3)
internal/webhook/ actor webhook dispatch (push to agents)
internal/notify/ ntfy + Telegram bridge (Task 6)
cmd/carin/ binary entrypoint + CLI client
client/ importable agent helper (Task 5)
webapp/ TypeScript/Svelte operations workspace served at /