No description
  • Go 76.7%
  • HTML 9%
  • Svelte 8.2%
  • CSS 3.2%
  • TypeScript 2.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Pyrrha 67fddf99e7
All checks were successful
carin/review Carin PR review verdict: approve
ci: remove Forgejo workflow — Carin handles its own PR checks
2026-07-12 12:09:23 -04:00
.hermes/plans feat: three-pane redesign, telegram approvals, token tracking, mobile responsive 2026-07-04 09:02:31 -04:00
client feat(client): agent registration + persona management methods 2026-07-05 11:46:10 -04:00
cmd/carin feat: durable generic webhook outbox — replace reliable:true rejection 2026-07-12 11:58:49 -04:00
docs feat: review-loop outbox, domain events, PWA, phone Today view 2026-07-12 10:49:33 -04:00
internal feat: durable generic webhook outbox — replace reliable:true rejection 2026-07-12 11:58:49 -04:00
model feat: durable generic webhook outbox — replace reliable:true rejection 2026-07-12 11:58:49 -04:00
webapp feat: review-loop outbox, domain events, PWA, phone Today view 2026-07-12 10:49:33 -04:00
.gitignore Build agentic web workspace 2026-07-04 13:43:10 -04:00
AGENTS.md Fix review defects: update docs, add ntfy Basic auth test 2026-07-04 18:10:20 -04:00
go.mod Task 1: SQLite store for messages and events 2026-06-24 18:36:27 -04:00
go.sum Task 1: SQLite store for messages and events 2026-06-24 18:36:27 -04:00
LICENSE Initial commit 2026-06-24 22:17:11 +00:00
plan.md Add plan.md 2026-06-24 22:17:47 +00:00
PR-TEST.md test: PR webhook E2E pipeline test 2026-07-11 22:54:31 -04:00
README.md Serve SPA at /, move HTMX dashboard to /legacy 2026-07-04 15:42:36 -04:00
research-durable-work-queues.md fix: all shell routes pass Summary, runs field names match, add /ui/overview 2026-07-03 23:06:01 -04:00
scan-results-2026-07-04.md Fix notification deep-link regression + notification enhancements 2026-07-04 19:11:35 -04:00

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, plus GET /messages/{id} and GET /threads/{id}.
  • Task 3 — Dashboard: server-rendered HTMX UI with the five sections from the plan.
  • Task 4 — SSE live updates: GET /stream pushes 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 -webhooks flag and internal/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/http routing, 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's thread_id equals its own id; replies inherit the parent's thread_id and set parent_id.
  • Timestamps: RFC3339 (UTC) text.
  • Validation: kind, status, and priority are validated against fixed sets; actor is 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 = high
  • kind = approval_request
  • kind = alert
  • status = 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/iteration forward.
  • revise, at the iteration cap (--max-iterations) → the pipeline stops and posts a high-priority approval_request to 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.

  1. Hermes webhook subscription. A carin-inbox subscription was created:

    hermes webhook subscribe carin-inbox \
      --prompt "New Carin message from {message.from}: {message.subject}..." \
      --skills carin-client \
      --deliver origin
    

    This exposes an endpoint at http://gateway:8644/webhooks/carin-inbox with an auto-generated HMAC secret. When Carin POSTs to it, Hermes validates the signature and spawns an agent run that loads the carin-client skill and processes the inbox (claim → act → reply → done/fail).

  2. Carin webhook config. A file at ~/.hermes/carin-webhooks.json tells 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.

  3. 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.

  4. CLI client. The carin binary is built and available at ~/projects/carin/carin. CARIN_URL and CARIN_ACTOR are 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:

  1. Clone and build:

    git clone https://durandal.exe.xyz/starsetbyte/Carin.git
    cd Carin && go build ./cmd/carin
    
  2. Set environment:

    export CARIN_URL=http://astraea1:8787   # or the Tailscale IP
    export CARIN_ACTOR=vesper
    
  3. Poll (or webhook). Vesper can poll with ./carin inbox on 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 the secret field.

  4. 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

  1. Build the carin binary (or import carin/client in Go).
  2. Set CARIN_ACTOR to your agent's name (free-text, no schema change needed).
  3. Point CARIN_URL at the running Carin server.
  4. Choose polling (carin inbox on a timer) or webhooks (add your URL to the server's webhook config).
  5. 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 /