An always-on agent that watches your vendors so you don't have to.
Unsyphn is an autonomous agent for vendor risk. It fetches every SaaS vendor's terms, pricing, DPA, sub-processors, security page, and SLA every 60 seconds; diffs each version with Gemini 2.5 Pro structured-output; fires policy rules; routes findings to Slack, Jira, Email, and Calendar; and publishes a public evidence brief through Senso. There is no Run button. The dashboard never polls — every state change rides a single org-scoped Server-Sent Events channel. When an operator buys the Compliance Pack, Stripe's webhook flips org entitlements live and the UI auto-updates over the same event bus.
Submission for the Datadog hackathon. Tracks A + B + C are integrated on main. The full product specification lives in handoff/ and should be read in this order: Product Decisions → API → Data Model → Runbook.
Status: 84/84 tests passing across 16 files · typecheck clean · build clean
Stack: Node 20+ · pnpm · Hono · Zod 4 · React 18 · Vite 5 · ClickHouse · pino
Auth: Bearer demo_token_acme_corp_2026 (or ?token= for EventSource)
Ports: API 8787 · Web 5173
- The problem
- The solution
- Sponsor tools used
- How we score against the judging criteria
- Architecture
- The agent pipeline
- Autonomy
- Monetization with Stripe + agent-driven entitlements
- The 3-minute demo script
- Application flows
- API surface
- SSE event channel
- Tech stack
- Repo layout
- Quick start
- Environment variables
- Seed data and demo state
- Testing
- Conventions
- What's not in the demo, and why
Enterprise SaaS contracts drift constantly. A vendor edits its DPA, adds a new sub-processor in a non-adequate jurisdiction, bumps its per-seat price 18%, shortens its retention from 90 days to 30 — and nobody on the customer's side notices until renewal day. By then the legal team is scrambling, the procurement team is eating the new pricing, and the security team is finding out their data has been sitting in a country it shouldn't be in.
The status quo answer to "who watches the vendor's terms page?" is "someone on the team will notice." Nobody notices. The vendor's email goes to a shared inbox. The PDF is buried in a Drive folder. The price line item gets approved because the AP clerk doesn't know it changed.
The market is real and underserved. Every mid-market and enterprise org has between 100 and 1,000 vendors and no good way to monitor them. The existing tools — Vendr, Zylo, Productiv — are spend-management products. They tell you what you're paying for, not what's changed under you. Unsyphn lives in the gap.
Stop relying on humans to notice. Run an always-on agent against the public surfaces of every vendor, diff every version against the last snapshot, classify the change with an LLM, fire policies, and route the result into the channels people actually read. By the time renewal comes around, the legal team already has a unsyphn, the security team already has an evidence brief, and procurement already has a renegotiation talking point.
In one screenshot, here's the loop end-to-end:
flowchart LR
subgraph "Agent loop · one tick per vendor every 60s"
direction LR
N[Nimble<br/>fetch] --> G[Gemini 2.5<br/>structured diff]
G --> CH[(ClickHouse<br/>snapshots)]
CH --> P[Policy DSL<br/>3 rules]
P --> R[Router]
end
R --> S[Slack<br/>Block Kit]
R --> J[Jira mock]
R --> E[Email mock]
R --> Cal[Calendar mock]
R --> SN[Senso<br/>evidence brief]
R --> EB((Event Broker<br/>SSE + Last-Event-ID))
EB -->|push| W[React + Vite<br/>Halo · SignIn · Dashboard]
ST[Stripe webhook] -->|payment_intent.succeeded| EB
One scheduler. One event bus. One shared contract package. The agent's outputs, the operator's lifecycle clicks, and Stripe's webhook all converge on the same broker — so the dashboard sees the world through a single ordered stream and never asks the server "what's new?"
The hackathon asks for at least three sponsor tools. Unsyphn wires six, each doing real work in the autonomous loop. Every integration has a typed provider in apps/api/src/providers/ and a documented fallback in the Runbook for the case where the provider is unavailable on demo day.
| # | Sponsor | Role in the loop | Code location | Fallback |
|---|---|---|---|---|
| 1 | Nimble | Headless fetch of vendor pages (terms, pricing, DPA, sub-processors, security, SLA) with anti-bot. 6 URLs per vendor per scan. | apps/api/src/providers/ + agent fetch stage |
Seeded snapshots in seed/ |
| 2 | Gemini 2.5 Pro | Structured-output diff reasoner. Takes (before, after, policyHints) → {severity, classification, materiality, dollarImpact?, citations[]} validated through Zod before reaching app logic. |
apps/api/src/providers/ + agent reason stage |
In-house heuristic classifier |
| 3 | ClickHouse Cloud | Append-only warehouse for Snapshots, ChangeReports, Actions, RunStages, OrgEntitlements. ReplacingMergeTree semantics. |
apps/api/src/db/client.ts, db/migrate.ts |
In-memory stores with the same interface |
| 4 | Senso | Public evidence-brief publishing. Each material ChangeReport produces a hosted brief URL surfaced in Slack and the dashboard. | apps/api/src/providers/ + agent publish stage |
Local route GET /v1/evidence/:id rendered by SensoBrief.tsx |
| 5 | Slack | Live Block Kit alert posted to #unsyphn-demo for every routed change. Vendor, severity, dollar impact, policy fired, citation snippet + URL, "Open in Unsyphn" / "View evidence" action buttons. |
apps/api/src/providers/slack.ts, agent/router.ts |
Payload still persists on the Action row if delivery fails |
| 6 | Stripe | Compliance Pack one-time purchase. Server-side PaymentIntent, Elements card form, signature-verified webhook, entitlement flip, org.entitlements.changed event over SSE so the UI auto-updates. |
apps/api/src/providers/stripe.ts, routes/billing.ts, routes/webhooks-stripe.ts, apps/web/src/screens/StripeModal.tsx |
Dev-only POST /v1/billing/simulate-success (returns 404 in prod) |
Tools 1, 2, 3, 4, 5 are the autonomous-agent half (fetch → reason → store → publish → notify). Tool 6 is the agent-payment-rail half — the agent reacts to the webhook event without a human pressing refresh.
Does the solution have the potential to solve a meaningful problem or demonstrate real-world value?
Unsyphn targets a real, underserved problem with a real buyer: procurement, legal, and GRC teams responsible for vendor risk in mid-market and enterprise organizations. The market gap is documented above in §1. The product is framed as an agent loop rather than yet another dashboard — the differentiator that lets us catch material change before renewal day rather than file a report after the fact. The product spec, severity matrix, three seeded policies (PII retention shrink, price hike near renewal, sub-processor added in non-adequate jurisdiction), and demo seed are all locked in handoff/Product Decisions.html before any code was shipped.
How well was the solution implemented?
| Signal | Evidence |
|---|---|
| Test coverage | 84 tests across 16 files, all passing (pnpm test) |
| Type safety | Zod schemas shared between API and web through @unsyphn/shared; branded TS IDs so cross-org leaks fail at compile time |
| Logging | Structured pino with request-scoped {requestId, orgId, operation}; zero console.* in production paths |
| Storage | Append-only with version history (ReplacingMergeTree semantics) — same interface in ClickHouse and in-memory dev store |
| Event bus | In-memory broker with Last-Event-ID replay, 500-event retention, per-event Zod validation at the boundary |
| Discriminated unions | Action = SlackAction | JiraAction | EmailAction | CalendarAction; exhaustive switches produce never |
| Security | Stripe webhook signature verification; bearer-over-query auth precedence; Zod validation at every external boundary |
| Build hygiene | pnpm typecheck and pnpm build clean; pre-commit hooks not skippable |
Did the solution effectively use at least 3 sponsor tools?
Six sponsor tools, all doing live work in the agent loop — itemized in §3 above with code locations and fallback strategies. Two are required by the prompt; we doubled the floor and integrated each one as a typed provider rather than a curl wrapper.
Demonstration of the solution in 3 minutes.
A second-by-second script lives in §9. It proves the loop end-to-end on live infrastructure: live Nimble fetch → live Gemini diff → live Slack post → live Senso publish → live Stripe charge → live entitlement flip via webhook → live UI auto-update over SSE — all visible in one browser tab. A pre-recorded MP4 of the exact same flow ships as a fallback in case a provider is throttling on demo day.
How well does the agent act on real-time data without manual intervention?
The agent has no Run button. The scheduler ticks every SCAN_INTERVAL_SEC (60 in demo, 21,600 in production), fans out the 7-stage pipeline, persists, publishes, and pushes — without any human input. The UI is read-only on top of the live event stream. The Stripe webhook closes the monetization loop autonomously. The one human-in-the-loop step is the ChangeReport lifecycle (new → acknowledged → resolved, with snoozed as a parallel branch), and that's deliberate: auto-resolving a P1 vendor change is the kind of "helpfulness" that breaks GRC trust on the first false positive.
flowchart TB
subgraph Open["Open web"]
TN[notion.so/terms]
TS[stripe.com/legal]
TP[vendor pricing pages]
TD[DPA · subprocessors · security · SLA]
end
subgraph Providers["Sponsor providers"]
N[Nimble]
G[Gemini 2.5 Pro]
SN[Senso]
SL[Slack]
SX[Stripe]
end
subgraph Backend["apps/api · Hono on Node 20"]
Sch[Scheduler<br/>every 60s per vendor]
Pipe[7-stage pipeline<br/>fetch · diff · reason · classify · route · publish · emit]
Bus[Event Broker<br/>in-memory · Last-Event-ID]
Auth[Bearer auth middleware]
Routes[REST + SSE routes]
end
subgraph Store["Persistence"]
CH[(ClickHouse Cloud<br/>append-only)]
Mem[In-memory stores<br/>vendors · policies · users<br/>seeded from seed/]
end
subgraph Web["apps/web · React 18 + Vite 5"]
Halo[Halo Landing /]
Sign[SignIn /sign-in]
App[Dashboard + Onboard /app]
Brief[SensoBrief /evidence/:id]
end
Open --> N --> Pipe
Pipe --> G
Pipe --> CH
Pipe --> SN
Pipe --> SL
Pipe --> Bus
SX -.webhook.-> Routes --> Bus
Bus -->|SSE push| App
Bus -->|SSE push| Sign
Routes --> Auth
Sch --> Pipe
Mem --> Pipe
One scheduler, one event bus, one typed contract package (@unsyphn/shared) shared between API and web. The SSE stream is the spinal cord — every component that needs to know about state change subscribes to it instead of polling.
Each scheduler tick per vendor runs the pipeline. Each stage emits a run.stage SSE event with status (started / completed / failed / skipped) and durationMs. The full sequence is visible in the SSE feed during the demo and persists to ClickHouse for post-hoc inspection.
sequenceDiagram
autonumber
participant Sched as Scheduler
participant Nim as Nimble
participant CH as ClickHouse
participant Gem as Gemini 2.5
participant Pol as Policy DSL
participant Rou as Router
participant Pub as Providers<br/>(Slack · Senso · Jira/Email/Cal)
participant Bus as Event Broker
participant UI as Web client
Sched->>Bus: scheduler.tick {vendorId, runId}
Bus-->>UI: scheduler.tick (SSE)
Sched->>Nim: fetch 6 monitored URLs
Nim-->>Sched: HTML snapshots
Sched->>Bus: run.stage {fetch, completed}
Sched->>CH: load prior snapshot
Sched->>Sched: text-diff per URL
Sched->>Bus: run.stage {diff, completed}
Sched->>Gem: classify diffs (structured output)
Gem-->>Sched: typed Change[] with citations
Sched->>Bus: run.stage {reason, completed}
Sched->>Pol: evaluate 3 policies
Pol-->>Sched: fired policies
Sched->>Bus: run.stage {classify, completed}
Sched->>Rou: route strings → ActionDraft[]
Rou-->>Sched: typed Actions
Sched->>Bus: run.stage {route, completed}
Sched->>CH: persist ChangeReport + Actions
Sched->>Pub: deliver each Action
Pub-->>Sched: externalId per Action
Sched->>Bus: run.stage {publish, completed}
Sched->>Bus: change.detected + action.delivered (× N)
Bus-->>UI: events fan out (SSE)
The stages are independent. A failed Slack delivery does not stop the Senso publish; a failed Senso publish does not roll back the persisted ChangeReport. Each delivery attempt persists an Action row even on failure, with the error message captured — so an operator can see what dropped, fix the upstream issue, and rerun without losing context.
| Stage | Provider | What it produces |
|---|---|---|
fetch |
Nimble | Raw HTML per monitored URL |
diff |
— | Text diff vs prior snapshot |
reason |
Gemini 2.5 Pro | Typed Change[] with citations + severity |
classify |
Policy DSL | Which of the 3 seeded policies fired |
route |
Router | ActionDraft[] — typed by kind |
publish |
ClickHouse + Senso + Slack + (mocked) Jira/Email/Cal | Persisted Actions with externalIds |
emit |
Event Broker | change.detected + N × action.delivered |
Every loop in Unsyphn runs without a human prompt except one. The scheduler ticks on setInterval, queues a scan job per vendor, and the pipeline runs through fetch → diff → reason → classify → route → publish → emit without input. Each Action is persisted, delivered, and confirmed automatically — Slack posts go up, Senso briefs publish, and an action.delivered event fans out to every connected client. The Stripe webhook is the same shape: payment_intent.succeeded arrives, the signature is verified, org entitlements flip, and an org.entitlements.changed event rides the SSE channel to the UI, which flips its Compliance Pack badge without a reload or a poll.
The one human-in-the-loop step is the ChangeReport lifecycle. The agent's job is to get every material change in front of the right operator with full context (vendor, citation, policy fired, dollar impact, recommended action) and then let the operator decide whether to renegotiate, accept, reject, or snooze for later. Auto-resolving a P1 is the kind of "helpfulness" that breaks GRC trust on the first false positive, so we don't.
| Loop | Trigger | Human in the loop? |
|---|---|---|
| Per-vendor scan | setInterval every SCAN_INTERVAL_SEC |
No |
| Diff + reason + classify + route | Each tick | No |
| Slack / Jira / Email / Calendar delivery | Each policy fired | No |
| Senso publish | Each ChangeReport | No |
| SSE fan-out | Each event published | No |
| Stripe entitlement upgrade | payment_intent.succeeded webhook |
No |
| ChangeReport state transition | Operator click | Yes — by design |
The admin trigger endpoint (POST /v1/admin/vendors/:id/scan) exists behind ADMIN_TOKEN for rehearsals and is not surfaced in the UI.
The Compliance Pack is a one-time $1,499 upgrade that unlocks SOC 2 evidence packaging, an auditor portal, and a multi-vendor compliance dashboard. The flow demonstrates the property the prompt is asking about: the agent reacting to a payment event without a human pressing refresh.
sequenceDiagram
autonumber
participant U as Operator
participant W as StripeModal (web)
participant API as Unsyphn API
participant SX as Stripe
participant H as Webhook handler
participant Bus as Event Broker
U->>W: Click "Upgrade to Compliance Pack"
W->>API: POST /v1/billing/payment-intents {sku: compliance-pack}
API->>SX: paymentIntents.create()
SX-->>API: clientSecret + publishableKey
API-->>W: {clientSecret, publishableKey, amountUsdCents}
W->>W: Mount Stripe Elements
U->>W: Enter card 4242 4242 4242 4242, pay
W->>SX: confirmCardPayment(clientSecret)
SX-->>W: payment_intent succeeded
SX->>H: POST /webhooks/stripe (signed)
H->>H: Verify signature via STRIPE_WEBHOOK_SECRET
H->>API: flip org.entitlements.compliancePack = true
H->>Bus: emit org.entitlements.changed
Bus-->>W: org.entitlements.changed (SSE)
W->>W: Flip to success state · show Compliance Pack badge
The web client is subscribed to the same /v1/stream channel the agent fans events out on, so the entitlement flip arrives via push, not poll.
On the agent-payment-rails note in the prompt (x402 / MPP / CDP / agentic.market): Unsyphn ships v1 with Stripe because it's the rail every team actually deploys today, and because the webhook + entitlement flow demonstrates the property those newer rails are after — the agent reacting to a payment event without a human in the loop. The architecture (typed Actions, event-bus-driven entitlements) is already shaped for a v2 x402 pass on the agent's own outputs (Senso briefs, evidence bundles, exported audit packs).
The script is timed second-by-second to fit the 3-minute slot and prove the loop end-to-end on live infrastructure.
| Time | Action | What the judges see |
|---|---|---|
| T+0:00 | Open http://localhost:5173 |
Halo landing renders with animated 3D cube. Narration: "Unsyphn is an always-on agent for vendor risk." |
| T+0:15 | Click Get access → /sign-in → Continue with demo workspace |
Lands on /app. Dashboard HUD shows 2 vendors · $242,000 annual run rate · ~$7,100 saved · 1 open change. |
| T+0:30 | Point at the live indicator | "No Run button — the scheduler ticks every 60s. Watch the SSE feed." A scheduler.tick event lands. |
| T+0:45 | (autonomous) | run.stage events stream through fetch → diff → reason → classify → route. change.detected fires for Notion: "Team plan price rises $16 → $19 (+18.75%) within 60d of renewal." |
| T+1:15 | Switch to Slack #unsyphn-demo |
Block Kit alert is already posted: severity, vendor, dollar impact, citation snippet, "Open in Unsyphn" / "View evidence" buttons. |
| T+1:30 | Click View evidence | Public Senso brief renders at /evidence/chg_seed_notion with citations linking to notion.so/pricing. |
| T+1:45 | Back to /app. Click Acknowledge on the change card |
change.stateChanged event fires, card animates to acknowledged state. Demonstrates lifecycle state machine. |
| T+2:00 | Click Upgrade to Compliance Pack → enter test card 4242 4242 4242 4242 → pay |
Stripe modal mounts Elements. Real PaymentIntent created and confirmed. |
| T+2:30 | (autonomous webhook) | action.delivered and org.entitlements.changed events land on SSE. Modal flips to success state without refresh. Compliance Pack badge appears in header. |
| T+2:50 | Close | "Six sponsor tools, one autonomous loop, real money moved in test mode, real Slack post, real Senso publish, real Nimble fetch, real Gemini diff." |
| T+3:00 | End | — |
Three routes coexist in a tiny pathname router in apps/web/src/App.tsx. The session lives in localStorage under unsyphn:bearer, and hasSession() from apps/web/src/lib/session.ts is the gate. A custom unsyphn:session window event fires on sign-in so the App re-renders without a reload.
flowchart LR
Anon[Anonymous visitor] --> Landing[/HaloLanding · /]
Landing -->|Get access| SignIn[/SignIn · /sign-in/]
SignIn -->|Continue with demo| Session[localStorage<br/>unsyphn:bearer]
Session --> App[/UnsyphnApp · /app<br/>Dashboard + Onboard/]
Anon -->|/evidence/:id| Brief[/SensoBrief — public/]
App -.click brief.-> Brief
sequenceDiagram
actor Op as Operator
participant Form as Onboard.tsx
participant API as POST /v1/vendors
participant Q as Queue
participant Pipe as 7-stage pipeline
participant Bus as Event Broker
participant UI as Web client (SSE)
Op->>Form: Fill name + homepage URL
Form->>API: POST /v1/vendors (Zod-validated)
API->>API: Discover 6 monitored URLs from homepage
API-->>Form: {id, firstScanRunId}
Form->>UI: Open /v1/stream, filter by runId
API->>Q: queueFirstScan(vendorId)
Q->>Pipe: run pipeline
loop 7 stages
Pipe->>Bus: run.stage {stage, status}
Bus-->>UI: SSE push
end
Pipe->>Bus: run.completed {status: changed|unchanged|failed}
Bus-->>UI: Flip to result panel
The state machine has four reachable states. Every successful mutation emits exactly one change.stateChanged event, and the repository keeps every version of the report so the audit log is the diff between versions.
stateDiagram-v2
[*] --> new : change.detected
new --> acknowledged : POST /acknowledge
acknowledged --> snoozed : POST /snooze<br/>(untilAt ≤ 30d)
acknowledged --> resolved : POST /resolve
new --> snoozed : POST /snooze
new --> resolved : POST /resolve
snoozed --> acknowledged : (snooze expires)
snoozed --> resolved : POST /resolve
resolved --> [*]
Acknowledge is only valid from new — re-acknowledging returns 409. Snooze rejects from resolved or snoozed, and untilAt must be future and ≤30 days. Resolve rejects from resolved.
Policy routes look like slack:@vendorOwner or email:ciso@acme.com. The router parses the route, resolves @vendorOwner to a real Slack user ID via the org's user roster, renders a typed payload (SlackPayload / JiraPayload / EmailPayload / CalendarPayload), calls the provider, and persists an Action row — whether delivery succeeded or failed. Failed deliveries persist with status: failed and an error message, and a failed action.delivered event still emits so operators see what dropped instead of silently losing the record.
Base: http://localhost:8787. All /v1/* requires Authorization: Bearer <token> except SSE, which accepts ?token=<token> (EventSource cannot set headers). Demo token: demo_token_acme_corp_2026.
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Liveness probe |
GET |
/v1/dashboard/summary |
HUD: vendor count, annual run rate, savings, next renewal, open change count |
POST |
/v1/vendors |
Create vendor + queue first scan; returns firstScanRunId |
GET |
/v1/changes/:id |
Latest version of a ChangeReport |
POST |
/v1/changes/:id/acknowledge |
new → acknowledged; emits change.stateChanged |
POST |
/v1/changes/:id/snooze |
Requires untilAt (future, ≤30d) |
POST |
/v1/changes/:id/resolve |
Requires resolution: accepted | renegotiated | rejected | no-action |
GET |
/v1/billing/products |
Compliance Pack catalog + current org entitlements |
POST |
/v1/billing/payment-intents |
Server-side PaymentIntent creation for a SKU |
POST |
/v1/billing/simulate-success |
Dev-only happy-path shortcut (404 in prod) |
GET |
/v1/evidence/:id |
Public ChangeReport brief — no auth |
GET |
/v1/stream |
SSE channel (see §12) |
POST |
/webhooks/stripe |
Stripe-signed webhook intake |
Every error path returns the same envelope shape:
{
"error": {
"code": "conflict",
"message": "Change is already acknowledged or in a later state",
"requestId": "req_01JCV0Z9MTYZ...",
"details": {}
}
}Error codes: validation-failed, unauthenticated, not-found, conflict, unprocessable, duplicate, discovery-incomplete, internal. The requestId is generated at the middleware boundary and propagates into every pino log line.
GET /v1/stream is org-scoped, sends a 15-second :heartbeat keepalive, uses monotonic evt_NNNNNN ids, and supports Last-Event-ID replay against the in-memory retained window (500 events default). Malformed cursors replay nothing rather than throwing or replaying everything. Per-event Zod validation at the broker boundary means an invalid event shape never reaches the wire.
| Event | Payload (abridged) | Fires when |
|---|---|---|
scheduler.tick |
{ vendorId, runId, startedAt } |
Per-vendor scan starts |
run.stage |
{ runId, stage, status, durationMs? } |
Each of the 7 stages enters/exits |
change.detected |
{ changeReportId, vendorId, severity, headline } |
Pipeline classifies a material change |
change.stateChanged |
{ changeReportId, state, by } |
Operator transitions a report |
action.delivered |
{ actionId, changeReportId, kind, status, externalId? } |
A routed Action is persisted (delivered or failed) |
org.entitlements.changed |
{ compliancePack, changedAt } |
Stripe webhook flips org state |
| Layer | Choice | Why |
|---|---|---|
| Runtime | Node 20+, pnpm workspaces | Top-level await, native fetch, AbortSignal.timeout |
| API | Hono on @hono/node-server |
Small, typed, SSE is first-class |
| Validation | Zod 4 | One schema, both sides — request parsing and inferred TS types |
| Storage | ClickHouse Cloud (append-only) + in-memory caches | ReplacingMergeTree for versioned reports |
| LLM | Gemini 2.5 Pro | Structured-output diff classification |
| Fetch | Nimble | Headless fetch with anti-bot, ~6 URLs per vendor per scan |
| Evidence | Senso | Public brief publishing |
| Notifications | Slack Block Kit (incoming webhook) + typed mocks for Jira/Email/Cal | One live channel, three documented contracts |
| Billing | Stripe Elements + PaymentIntents + webhook | One-time Compliance Pack purchase |
| Frontend | React 18 + Vite 5 | Halo landing, sign-in, dashboard, public brief |
| Logging | pino | Structured JSON, request-scoped context |
| Tests | Vitest 4 + @testing-library/react |
One runner, both sides; jsdom via environmentMatchGlobs |
| IDs | ulid | Sortable, opaque, prefix-tagged |
.
├── apps/
│ ├── api/ Hono server, agent pipeline, providers
│ │ └── src/
│ │ ├── index.ts Prod entry: loadSeeds → migrate → serve
│ │ ├── server.ts createApp + dev --seed shortcut
│ │ ├── app.ts Hono assembly, CORS + auth + error middleware
│ │ ├── auth.ts Bearer token middleware
│ │ ├── logger.ts pino instance
│ │ ├── env.ts Validated env access
│ │ ├── agent/ Router, queue, stub-runner
│ │ ├── routes/ changes · stream · dashboard · vendors ·
│ │ │ billing · evidence · webhooks-stripe
│ │ ├── providers/ slack · stripe (nimble · gemini · senso land here)
│ │ ├── stream/ Event broker + SSE event helpers
│ │ ├── db/ In-memory stores + ClickHouse client + migrate
│ │ └── seed/ Factories + JSON loader
│ └── web/ React + Vite client
│ └── src/
│ ├── main.tsx
│ ├── App.tsx Pathname router
│ ├── screens/ HaloLanding · SignIn · Onboard · StripeModal · SensoBrief
│ ├── hooks/ useHaloCube (54-tile 3D scene)
│ ├── lib/ api · session · dashboard · stream
│ └── styles/ halo.css
├── packages/
│ └── shared/ Zod schemas + TS types shared by api + web
├── tests/
│ ├── api/ 12 suites
│ ├── web/ 5 suites
│ ├── helpers/ In-memory stores for tests
│ └── setup.ts vitest setup (env + jest-dom + cleanup)
├── seed/ orgs · users · vendors · policies · tokens · change-reports
├── handoff/ The 5-doc product spec (read first)
└── .env.example 14 documented env vars
The dependency graph between packages is straightforward and acyclic:
graph LR
Shared[packages/shared<br/>Zod + TS types]
API[apps/api]
Web[apps/web]
Tests[tests/]
API --> Shared
Web --> Shared
Tests --> API
Tests --> Web
Tests --> Shared
A contract change in @unsyphn/shared rebuilds the universe with one pnpm typecheck.
pnpm install
cp .env.example .env.local # fill in keys; .env* is gitignored
pnpm test # vitest — should report 84/84 passing
pnpm typecheck # tsc --noEmit across the workspace
pnpm dev # api on :8787 + web on :5173 (parallel)Run one side at a time:
pnpm dev:api # Hono with full seed load + ClickHouse migrate
pnpm dev:api -- --seed # in-memory only; one ChangeReport seeded
pnpm dev:web # Vite dev server, /v1 proxied to :8787Open http://localhost:5173. The flow is / (Halo landing, public) → /sign-in (paste token or use demo workspace) → /app (dashboard HUD + Add-Vendor form, requires session). /evidence/:id is always accessible publicly for the Senso fallback brief.
Fourteen variables documented in .env.example. Required for the full demo path:
| Variable | Used by | If unset |
|---|---|---|
CLICKHOUSE_URL / _USER / _PASSWORD |
Snapshot + ChangeReport + Action writes | API boot fails |
NIMBLE_API_KEY |
Live vendor page fetches | Falls back to seeded snapshots |
GEMINI_API_KEY |
Structured diff classification | Falls back to heuristic classifier |
SLACK_WEBHOOK_URL |
Posting to #unsyphn-demo |
Action persisted, never delivered |
STRIPE_SECRET_KEY / _PUBLISHABLE_KEY / _WEBHOOK_SECRET |
Compliance Pack purchase | /v1/billing/* returns 503 |
BASE_URL |
Embedded in outgoing Slack links | Defaults to http://localhost:8787 |
Optional: NODE_ENV, PORT (8787), LOG_LEVEL (info), SCAN_INTERVAL_SEC (60 demo / 21600 prod), SENSO_API_KEY (falls back to local /v1/evidence/:id), ADMIN_TOKEN (guards admin scan trigger).
seed/ holds the deterministic demo state. Loaded by apps/api/src/seed/loader.ts at boot.
| File | Count | Highlights |
|---|---|---|
orgs.json |
1 | Acme Corp (org_acme), 142 seats, Compliance Pack off |
users.json |
6 | Priya (procurement), Marcus, Lin, Jordan, Ada, Devon |
vendors.json |
2 | Notion — Tier 1, risk, $158k/yr, renews 2026-07-04 · Stripe — Tier 1, watch, $84k/yr, renews 2026-07-18 |
policies.json |
3 | PII retention shrink · Price hike >10% near renewal · Sub-processor added in non-adequate jurisdiction |
change-reports.json |
2 | Notion retention + price hike (P1, new) · Stripe sub-processor add (P1, acknowledged) |
tokens.json |
1 | demo_token_acme_corp_2026 → org_acme |
For lightweight curl loops, pnpm dev:api -- --seed boots in-memory only with one fresh ChangeReport (chg_seed_notion_yesterday).
Try it:
TOKEN=demo_token_acme_corp_2026
# Dashboard HUD aggregate
curl -s "http://localhost:8787/v1/dashboard/summary" \
-H "Authorization: Bearer $TOKEN" | jq
# Acknowledge a change
curl -s -X POST "http://localhost:8787/v1/changes/chg_seed_notion_yesterday/acknowledge" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"note":"Reviewed by vendor owner"}' | jq
# Subscribe to the SSE stream (in another terminal)
curl -N "http://localhost:8787/v1/stream?token=$TOKEN"pnpm test # vitest run — 84/84 across 16 files
pnpm test:watch # watch mode
pnpm --filter @unsyphn/api typecheck # one workspaceThe test layout splits cleanly. tests/api/ covers request and response shape, store invariants, broker behavior, lifecycle transitions, SSE ordering and replay, Slack rendering, Stripe webhook signature verification, vendor discovery, agent routing, billing flows, and the integration test that proves lifecycle events and action events share one SSE history. tests/web/ covers HaloLanding, SignIn, Onboard, StripeModal, and SensoBrief — scoped to jsdom via environmentMatchGlobs in vitest.config.ts. tests/setup.ts seeds env vars so env() passes in test, registers @testing-library/jest-dom, and runs cleanup() after each test. tests/helpers/ holds in-memory stores so route tests never need to touch ClickHouse.
Current state: 84 tests across 16 files, all passing. Typecheck and build clean. No console.* anywhere in production paths.
Immutability is enforced through spread and map/filter; no in-place mutation in app code. Zod parses every external payload — HTTP request body, webhook body, LLM output, SSE event in flight — before it reaches app logic, so an unexpected shape produces a typed error instead of a runtime explosion. Logging is structured through pino with requestId, orgId, and operation bound at the request middleware boundary; production code has zero console.* calls. IDs are branded TypeScript types (OrgId, UserId, VendorId, ChangeReportId, ActionId), so a function that takes a VendorId will not accept an OrgId even though both are strings at runtime — cross-org leaks become compile errors.
Action is a discriminated union on kind, so SlackAction | JiraAction | EmailAction | CalendarAction each carry their own typed payload (SlackPayload, JiraPayload, EmailPayload, CalendarPayload) and exhaustive switches over action.kind produce never on the default branch. The ChangeReport store is append-only — every state transition keeps the prior version, and getLatest resolves the most recent by updatedAt and version — mirroring ClickHouse ReplacingMergeTree semantics so the in-memory dev store and the production warehouse behave identically.
Per-event SSE validation at the broker boundary means an invalid event shape can never reach a connected client. Last-Event-ID replay handles the common reconnect case; malformed cursors replay nothing rather than throwing. The Stripe webhook verifies its signature against STRIPE_WEBHOOK_SECRET before doing anything with the payload — no signature, no entitlement flip. Bearer-over-query auth: when both an Authorization header and ?token= are present, the header wins; the query token is only there for EventSource. Tests come before features — every new behavior gets a Vitest spec first, and --no-verify on commit is blocked by a pre-commit hook so the test gate cannot be bypassed.
| Capability | Status | Why |
|---|---|---|
| Jira / Email / Calendar live delivery | Mocked with typed contracts | Slack carries the demo. The other three persist Actions and emit action.delivered through the same code path — same architecture, no third-party signups for judges. |
| Auditor portal | v2 | Behind Compliance Pack entitlement; the entitlement flip works, the gated UI doesn't exist yet. |
| Per-vendor scan history UI | v2 | The run.stage events all persist to ClickHouse, so the data is there for a v2 inspector screen. |
| Real OAuth sign-in | Demo bearer + localStorage | Hackathon-scoped; the gate is intentionally narrow so it can be swapped later. |
| x402 / MPP / CDP inline pricing on agent outputs | v2 | Stripe Compliance Pack is the v1 monetization; the architecture is already shaped for x402 on Senso briefs / exported audit packs. |
Everything else in this README is live in the repo and covered by tests.
Don't commit real keys or local .env* files (gitignored — check before staging). Don't push directly to main; every change goes through a sprint PR. Don't skip pre-commit hooks (--no-verify is blocked). Don't add console.* to production code — use the pino logger.
Do read handoff/ before touching anything. The product is locked there.