# Project export: Precedent

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: A context graph for enterprise decision traces
- Devpost: https://devpost.com/software/precedent
- GitHub: https://github.com/tranngocsongtruc/precedent
- Team: 1 GitHub contributor(s) — Truc Tran (3 commits)

## Devpost submission (written by the team)

### Inspiration

Enterprise CRMs record what was decided--"25% discount approved"--but throw away the why. The procurement context, the incident history, the comparable deal from last quarter, the policy rule that applied, who signed off and on what grounds: all of it evaporates into Slack threads and someone's memory. When the same situation comes up again, the reasoning is re-litigated from scratch, and inconsistent exceptions quietly erode margin. I wanted the reasoning itself to become a durable, queryable asset--a system of record for decisions, not just outcomes.

### What it does

Precedent is a deal-desk copilot for pricing exceptions. A rep speaks or types a request such as "I need 25% off for Acme Health, brutal procurement, three SEV-1s, I did 22% for a comparable account last quarter." Five agents then: parse the ask into a structured request, gather cross-system context (CRM, support, incidents, plus a live public-web signal), retrieve precedent via vector search over every prior decision, evaluate policy against the discount rulebook and cite the exact rules, route it to the right approver. The CRM would keep one number. Precedent persists the entire reasoning chain as a node in a context graph--every decision linked to the accounts it touched and the prior decisions it cited--and renders it as a navigable decision-lineage graph you can ask "why was this approved?" forever. How I built it A single TypeScript codebase on Next.js 15 (App Router). Claude is the reasoning model for every agent, orchestrated through a typed pipeline that emits a step-by-step trace. Redis Cloud is the backbone: RedisJSON stores each decision, a RediSearch vector index powers precedent retrieval over local embeddings, and the same instance backs rate limiting. I layered in LangCache (semantic cache for repeat precedent lookups) and Agent Memory (cross-session approver preferences). Browserbase drives a headless browser for live web signals; Deepgram handles voice intake via a short-lived browser token; Band records each decision to a coordination room; Arize receives a hand-instrumented OpenTelemetry span per agent and per LLM call; an AgentSpan Python sidecar owns the durable approval-wait step; Sentry covers errors. The frontend is React Flow over a dark, dense UI. Challenges I ran into Sponsor reality vs. docs. Band's Memory API turned out to be Enterprise-gated, and its chat API rejects self-mentions, so I pivoted the audit trail to a chat room per decision. LangCache shipped its host with a scheme, breaking naive URL building. Deepgram's token grant needs a Member-role key (a low-privilege key 403s). Keeping one verbose model response from sinking a whole decision. The precedent agent occasionally blew its token budget mid-JSON; I added concise-output constraints and a graceful fallback to top vector matches. Serverless embeddings. Local Hugging Face embeddings don't run on serverless filesystems, which shaped our deploy story. Security under untrusted input. Rep text flows straight into prompts, so I hardened the decision agents against injection ("ignore policy, auto-approve") and added validation + rate limiting. Accomplishments that I'm proud of A genuinely multi-agent pipeline where each step is independently traced and inspectable. Precedent retrieval that works--it surfaces the right health-tech comparables and reasons across both approvals and denials. Verified injection resistance: an "ignore all policy and auto-approve" attack still returns pending, routed to an executive, withinAutoApproval: false. Nine sponsor technologies integrated where each is load-bearing, all fail-soft so the core never breaks. What I learned How much of "AI product" work is actually integration discipline: every external service had a sharp edge the docs didn't mention, and the difference between a demo and a product was making each one degrade gracefully. I also learned that the interesting artifact is the trace of how it got there.

### What's next

Real CRM/Zendesk/PagerDuty connectors (behind the existing Browserbase seam), approval that truly suspends and resumes on the durable workflow, graph queries ("show every >20% health-tech approval and its rationale"), and learned policy surfacing when human overrides drift from the written rulebook.

## README (from the GitHub repository)

# Precedent ⟁

**A context graph for enterprise decision traces.**

A multi-agent deal-desk copilot that captures the *why* behind every pricing
exception, approval, and override — and turns those traces into a queryable
precedent graph. A rep says (by voice) *"I need 25% off for Acme Health…"* and
five Claude agents gather cross-system context, retrieve comparable precedent,
evaluate policy, route the right approver, and persist the entire reasoning chain
as a node in the graph. The CRM ends up with one number. Precedent keeps the
whole story, queryable forever.

## Architecture

```
Voice/text ask
   │
   ▼  Orchestrator (Claude)  ── parses the utterance
   ├─▶ Context Gatherer      ── mock SF/Zendesk/PagerDuty + live web via Browserbase
   ├─▶ Precedent Retriever   ── Redis vector KNN over prior decisions (+ LangCache)
   ├─▶ Policy Evaluator      ── reasons over the discount rulebook, cites rules
   └─▶ Approver Router       ── maps to an approver (+ Agent Memory preferences)
   │
   ▼  Decision node persisted to Redis (JSON + vector)  ──▶  decision-lineage graph
      • mirrored to Band audit trail   • traced span-by-span to Arize
      • pending → durable approval on the AgentSpan sidecar
```

## Sponsor integrations

| Sponsor | Role | How it's wired |
|---|---|---|
| **Anthropic / Claude** | Reasoning model for all 5 agents | `@anthropic-ai/sdk`, `src/lib/anthropic.ts` |
| **Redis** | Precedent graph + vector search (`Redis Cloud`) | RedisJSON + RediSearch vector index, `src/lib/redis.ts` |
| **Redis LangCache** | Semantic cache for repeated precedent lookups | REST, `src/lib/langcache.ts` |
| **Redis Agent Memory** | Cross-session approver/routing memory | REST, `src/lib/agentMemory.ts` |
| **Browserbase** | Live public-web signals (real cross-system pull) | SDK + `playwright-core` over CDP, `src/connectors/browserbase.ts` |
| **Deepgram** | Live voice intake (mic → transcript) | granted JWT + browser WS, `src/components/VoiceButton.tsx` |
| **Band.ai** | Cross-agent audit trail | REST Agent API, `src/lib/band.ts` |
| **Arize AX** | OpenTelemetry tracing per agent | manual OTel spans, `src/lib/tracing.ts` |
| **AgentSpan** | Durable approval-wait workflow | Python sidecar, `sidecar/` |
| **Sentry** | Error monitoring | `/install-plugin sentry` (see below) |

Every optional integration is **fail-soft**: with no credentials it's a no-op and
the core flow (Claude + Redis) still runs end to end.

## Setup

### 1. Install + configure

```bash
npm install
cp .env.example .env   # then fill in keys
```

**Minimum to run:** `ANTHROPIC_API_KEY` + Redis (`REDIS_URL` or host/port/password).
Everything else is additive. Embeddings run locally (no key) by default.

Get a free Redis Cloud DB (30 MB, includes Vector Search) at
[redis.io/try-free](https://redis.io/try-free/); the connection string looks like
`redis://default:<password>@<host>:<port>`.

### 2. Initialize the graph

```bash
npm run redis:init   # creates the RediSearch vector index
npm run seed         # loads ~10 prior decisions so precedent search has history
```

### 3. Run

```bash
npm run dev          # http://localhost:3000
```

### 4. (Optional) Durable approval sidecar

See [`sidecar/README.md`](sidecar/README.md) — Python + AgentSpan, on `:8088`.

### 5. (Optional) Sentry

`@sentry/nextjs` is already wired (`sentry.*.config.ts`, `src/instrumentation*.ts`,
`withSentryConfig` in `next.config.mjs`, plus `captureException` in the API routes).
To activate it, create a project at [sentry.io](https://sentry.io), copy the DSN,
and set `SENTRY_DSN` + `NEXT_PUBLIC_SENTRY_DSN` in `.env`. With no DSN it stays
inert. (The `/install-plugin sentry` Claude Code plugin is unrelated — not needed.)

## Tech

Next.js 15 (App Router) · TypeScript · React Flow (graph viz) · Tailwind ·
`@huggingface/transformers` local embeddings (384-dim, swappable to Voyage).


## Detected evidence (automated analysis)

Indexed codebase: 87 recognized source files, 471 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- Next.js (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Redis (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- Vercel (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (98 of 98)

```
.agents/skills/arize-admin/references/ax-profiles.md
.agents/skills/arize-admin/references/ax-setup.md
.agents/skills/arize-admin/references/REFERENCE.md
.agents/skills/arize-admin/SKILL.md
.agents/skills/arize-ai-provider-integration/references/ax-profiles.md
.agents/skills/arize-ai-provider-integration/references/ax-setup.md
.agents/skills/arize-ai-provider-integration/SKILL.md
.agents/skills/arize-annotation/references/ax-profiles.md
.agents/skills/arize-annotation/references/ax-setup.md
.agents/skills/arize-annotation/SKILL.md
.agents/skills/arize-compliance-audit/references/compliance-checklist-template.md
.agents/skills/arize-compliance-audit/references/eu-ai-act-gpai.md
.agents/skills/arize-compliance-audit/references/iso-42001.md
.agents/skills/arize-compliance-audit/references/us-ai-compliance.md
.agents/skills/arize-compliance-audit/SKILL.md
.agents/skills/arize-dataset/references/ax-profiles.md
.agents/skills/arize-dataset/references/ax-setup.md
.agents/skills/arize-dataset/SKILL.md
.agents/skills/arize-evaluator/references/ax-profiles.md
.agents/skills/arize-evaluator/references/ax-setup.md
.agents/skills/arize-evaluator/SKILL.md
.agents/skills/arize-experiment/references/ax-profiles.md
.agents/skills/arize-experiment/references/ax-setup.md
.agents/skills/arize-experiment/SKILL.md
.agents/skills/arize-instrumentation/references/ax-profiles.md
.agents/skills/arize-instrumentation/references/integration-routing.md
.agents/skills/arize-instrumentation/references/manual-spans.md
.agents/skills/arize-instrumentation/references/tracing-assistant-mcp.md
.agents/skills/arize-instrumentation/SKILL.md
.agents/skills/arize-link/references/EXAMPLES.md
.agents/skills/arize-link/SKILL.md
.agents/skills/arize-prompt-optimization/references/ax-profiles.md
.agents/skills/arize-prompt-optimization/references/ax-setup.md
.agents/skills/arize-prompt-optimization/SKILL.md
.agents/skills/arize-prompts/references/ax-profiles.md
.agents/skills/arize-prompts/references/ax-setup.md
.agents/skills/arize-prompts/references/cli-prompts.md
.agents/skills/arize-prompts/SKILL.md
.agents/skills/arize-trace/references/ax-profiles.md
.agents/skills/arize-trace/references/ax-setup.md
.agents/skills/arize-trace/SKILL.md
.env.example
.gitignore
LICENSE
next.config.mjs
package.json
postcss.config.mjs
README.md
scripts/initRedis.ts
scripts/loadEnv.ts
scripts/resetRedis.ts
scripts/seed.ts
scripts/testArize.ts
sentry.edge.config.ts
sentry.server.config.ts
sidecar/app.py
sidecar/README.md
sidecar/requirements.txt
skills-lock.json
src/agents/approverRouter.ts
src/agents/contextGatherer.ts
src/agents/evaluator.ts
src/agents/orchestrator.ts
src/agents/policy.ts
src/agents/policyEvaluator.ts
src/agents/precedentRetriever.ts
src/app/api/decide/route.ts
src/app/api/deepgram/token/route.ts
src/app/api/durable/approval/[id]/route.ts
src/app/api/durable/approval/route.ts
src/app/api/graph/route.ts
src/app/api/sentry-example-api/route.ts
src/app/global-error.tsx
src/app/globals.css
src/app/layout.tsx
src/app/page.tsx
src/app/sentry-example-page/page.tsx
src/components/DecisionGraph.tsx
src/components/ThemeToggle.tsx
src/components/VoiceButton.tsx
src/connectors/browserbase.ts
src/connectors/mock.ts
src/instrumentation-client.ts
src/instrumentation.ts
src/lib/agentMemory.ts
src/lib/anthropic.ts
src/lib/band.ts
src/lib/embeddings.ts
src/lib/env.ts
src/lib/guard.ts
src/lib/langcache.ts
src/lib/redis.ts
src/lib/tracing.ts
src/lib/types.ts
tailwind.config.ts
tsconfig.json
tsconfig.tsbuildinfo
vercel.json
```

### Dependencies

- package.json: @anthropic-ai/sdk@^0.39.0, @arizeai/openinference-semantic-conventions@^2.5.0, @browserbasehq/sdk@^2.14.1, @deepgram/sdk@^5.4.0, @huggingface/transformers@^3.3.0, @opentelemetry/api@^1.9.1, @opentelemetry/exporter-trace-otlp-proto@^0.219.0, @opentelemetry/resources@^2.8.0, @opentelemetry/sdk-trace-node@^2.8.0, @opentelemetry/semantic-conventions@^1.41.1, @sentry/nextjs@^10.59.0, @types/node@^22.10.7, @types/react@^19.0.7, @types/react-dom@^19.0.3, autoprefixer@^10.4.20, framer-motion@^12.40.0, next@^15.1.6, playwright-core@^1.61.0, postcss@^8.5.1, react@^19.0.0, react-dom@^19.0.0, reactflow@^11.11.4, redis@^4.7.0, tailwindcss@^3.4.17, tsx@^4.19.2, typescript@^5.7.3, zod@^3.24.1
- sidecar/requirements.txt: agentspan, fastapi, pydantic, uvicorn[standard]

### Recent commits (newest first)

- AgentSpan sidecar: surface ANTHROPIC_API_KEY presence in /health (worker needs it for litellm model calls)
- AgentSpan (Orkes) HITL: durable approval run that pauses for human sign-off; approve/deny from UI + status proxy
- Enrich Arize root span with decision outcome + eval score (meaningful, filterable traces)
- Stream agent reasoning live (SSE) into a dedicated column; 3-pane layout; highlight new decision in graph + legend
- Default embeddings to Voyage when key present + fall back to Voyage if local stack missing (fixes serverless transformers import)
- Resilience: fail-soft Arize tracing setup + strip quotes/whitespace from env values (fixes deployed 'Invalid URL')
- Add Arize LLM-judge evaluator + auto-refine loop; force-flush traces to Arize; fix voice transcript editing; add onboarding/help UI
- Fix Vercel 250MB function limit: exclude transformers/onnxruntime/sharp from serverless trace (Voyage used in prod); add Voyage 429 backoff
- Make embeddings backend-aware (local 384 / Voyage 512); auto-rebuild index on dim change + redis:reset script
- Pin Vercel framework to Next.js (override dashboard preset) + decide fn duration
- Band approver-thread, voice bearer-auth fix, navy/cream/gold theme, tech-visibility panel
- Premium UI redesign: Linear-style theming, dark/light toggle, motion
- Build Precedent: 5-agent deal-desk copilot + sponsor integrations + guardrails
- Initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### .agents/skills/arize-link/SKILL.md

```markdown
---
name: arize-link
description: Generates deep links to the Arize UI for traces, spans, sessions, datasets, labeling queues, evaluators, and annotation configs. Produces clickable URLs for sharing Arize resources with team members. Use when the user wants to link to or open a trace, span, session, dataset, evaluator, or annotation config in the Arize UI.
metadata:
  author: arize
  version: "1.0"
---

# Arize Link

Generate deep links to the Arize UI for traces, spans, sessions, datasets, labeling queues, evaluators, and annotation configs.

## When to Use

- User wants a link to a trace, span, session, dataset, labeling queue, evaluator, or annotation config
- You have IDs from exported data or logs and need to link back to the UI
- User asks to "open" or "view" any of the above in Arize

## Required Inputs

Collect from the user or context (exported trace data, parsed URLs):

| Always required | Resource-specific |
|---|---|
| `org_id` (base64) | `project_id` + `trace_id` [+ `span_id`] — trace/span |
| `space_id` (base64) | `project_id` + `session_id` — session |
| | `dataset_id` — dataset |
| | `queue_id` — specific queue (omit for list) |
| | `evaluator_id` [+ `version`] — evaluator |

**All path IDs must be base64-encoded** (characters: `A-Za-z0-9+/=`). A raw numeric ID produces a valid-looking URL that 404s. If the user provides a number, ask them to copy the ID directly from their Arize browser URL (`https://app.arize.com/organizations/{org_id}/spaces/{space_id}/…`). If you have a raw internal ID (e.g. `Organization:1:abC1`), base64-encode it before inserting into the URL.

## URL Templates

Base URL: `https://app.arize.com` (override for on-prem)

**Trace** (add `&selectedSpanId={span_id}` to highlight a specific span):
```
{base_url}/organizations/{org_id}/spaces/{space_id}/projects/{project_id}?selectedTraceId={trace_id}&queryFilterA=&selectedTab=llmTracing&timeZoneA=America%2FLos_Angeles&startA={start_ms}&endA={end_ms}&envA=tracing&modelType=generative_llm
```

**Session:**
```
{base_url}/organizations/{org_id}/spaces/{space_id}/projects/{project_id}?selectedSessionId={session_id}&queryFilterA=&selectedTab=llmTracing&timeZoneA=America%2FLos_Angeles&startA={start_ms}&endA={end_ms}&envA=tracing&modelType=generative_llm
```

**Dataset** (`selectedTab`: `examples` or `experiments`):
```
{base_url}/organizations/{org_id}/spaces/{space_id}/datasets/{dataset_id}?selectedTab=examples
```

**Queue list / specific queue:**
```
{base_url}/organizations/{org_id}/spaces/{space_id}/queues
{base_url}/organizations/{org_id}/spaces/{space_id}/queues/{queue_id}
```

**Evaluator** (omit `?version=…` for latest):
```
{base_url}/organizations/{org_id}/spaces/{space_id}/evaluators/{evaluator_id}
{base_url}/organizations/{org_id}/spaces/{space_id}/evaluators/{evaluator_id}?version={version_url_encoded}
```
The `version` value must be URL-encoded (e.g., trailing `=` → `%3D`).

**Annotation configs:**
```
{base_url}/organizations/{org_id}/spaces/{space_id}/a
[truncated — 1481 more characters]
```

### .agents/skills/arize-ai-provider-integration/SKILL.md

```markdown
---
name: arize-ai-provider-integration
description: Creates, reads, updates, and deletes Arize AI integrations that store LLM provider credentials used by evaluators and other Arize features. Supports any LLM provider (e.g. OpenAI, Anthropic, Azure OpenAI, AWS Bedrock, Vertex AI, Gemini, NVIDIA NIM). Use when the user mentions AI integration, LLM provider credentials, create integration, list integrations, update credentials, delete integration, or connecting an LLM provider to Arize.
metadata:
  author: arize
  version: "1.0"
compatibility: Requires the ax CLI and a configured Arize profile.
---

# Arize AI Integration Skill

> **`SPACE`** — Most `--space` flags and the `ARIZE_SPACE` env var accept a space **name** (e.g., `my-workspace`) or a base64 space **ID** (e.g., `U3BhY2U6...`). Find yours with `ax spaces list`.
> **Note:** `ai-integrations create` does **not** accept `--space` — AI integrations are account-scoped. Use `--space` only with `list`, `get`, `update`, and `delete`.

## Concepts

- **AI Integration** = stored LLM provider credentials registered in Arize; used by evaluators to call a judge model and by other Arize features that need to invoke an LLM on your behalf
- **Provider** = the LLM service backing the integration (e.g., `openAI`, `anthropic`, `awsBedrock`)
- **Integration ID** = a base64-encoded global identifier for an integration (e.g., `TGxtSW50ZWdyYXRpb246MTI6YUJjRA==`); required for evaluator creation and other downstream operations
- **Scoping** = visibility rules controlling which spaces or users can use an integration
- **Auth type** = how Arize authenticates with the provider: `default` (provider API key), `proxy_with_headers` (proxy via custom headers), or `bearer_token` (bearer token auth)

## Prerequisites

Proceed directly with the task — run the `ax` command you need. Do NOT check versions, env vars, or profiles upfront.

If an `ax` command fails, troubleshoot based on the error:
- `command not found` or version error → see references/ax-setup.md
- `401 Unauthorized` / missing API key → run `ax profiles show` to inspect the current profile. If the profile is missing or the API key is wrong, follow references/ax-profiles.md to create/update it. If the user doesn't have their key, direct them to https://app.arize.com/admin > API Keys
- Space unknown → run `ax spaces list` to pick by name, or ask the user
- LLM provider call fails (missing OPENAI_API_KEY / ANTHROPIC_API_KEY) → run `ax ai-integrations list --space SPACE` to check for platform-managed credentials. If none exist, ask the user to provide the key or create an integration via the **arize-ai-provider-integration** skill
- **Security:** Never read `.env` files or search the filesystem for credentials. Use `ax profiles` for Arize credentials and `ax ai-integrations` for LLM provider keys. If credentials are not available through these channels, ask the user.

---

## List AI Integrations

List all integrations accessible in a space:

```bash
ax ai-integ
[truncated — 7824 more characters]
```

### package.json

```
{
  "name": "precedent",
  "version": "0.1.0",
  "private": true,
  "description": "A context graph for enterprise decision traces",
  "license": "Apache-2.0",
  "type": "module",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "seed": "tsx scripts/seed.ts",
    "redis:init": "tsx scripts/initRedis.ts",
    "redis:reset": "tsx scripts/resetRedis.ts"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.39.0",
    "@arizeai/openinference-semantic-conventions": "^2.5.0",
    "@browserbasehq/sdk": "^2.14.1",
    "@deepgram/sdk": "^5.4.0",
    "@huggingface/transformers": "^3.3.0",
    "@opentelemetry/api": "^1.9.1",
    "@opentelemetry/exporter-trace-otlp-proto": "^0.219.0",
    "@opentelemetry/resources": "^2.8.0",
    "@opentelemetry/sdk-trace-node": "^2.8.0",
    "@opentelemetry/semantic-conventions": "^1.41.1",
    "@sentry/nextjs": "^10.59.0",
    "framer-motion": "^12.40.0",
    "next": "^15.1.6",
    "playwright-core": "^1.61.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "reactflow": "^11.11.4",
    "redis": "^4.7.0",
    "zod": "^3.24.1"
  },
  "devDependencies": {
    "@types/node": "^22.10.7",
    "@types/react": "^19.0.7",
    "@types/react-dom": "^19.0.3",
    "autoprefixer": "^10.4.20",
    "postcss": "^8.5.1",
    "tailwindcss": "^3.4.17",
    "tsx": "^4.19.2",
    "typescript": "^5.7.3"
  }
}

```

### sidecar/requirements.txt

```
agentspan
fastapi
uvicorn[standard]
pydantic

```

### sidecar/app.py

```python
"""
AgentSpan (by Orkes) durable-workflow sidecar — human-in-the-loop approval.

This is the one step in Precedent that genuinely needs durable execution:
parking a pricing exception while it waits for a human approver. We model it
with AgentSpan's first-class HITL primitive — a tool marked
`approval_required=True`. When the coordinator agent calls it, the run PAUSES
server-side on the AgentSpan engine and waits (indefinitely, no in-memory
state at risk). A human approves/denies from the AgentSpan UI (localhost:6767),
the AgentSpan CLI, or Precedent's own buttons — and the workflow resumes.

Run:
    cd sidecar
    python -m venv .venv && source .venv/bin/activate     # Python 3.10+ recommended
    pip install -r requirements.txt
    agentspan server start            # durable engine + UI at http://localhost:6767
    uvicorn app:app --port 8088       # this sidecar (matches AGENTSPAN_SIDECAR_URL)

Env: ANTHROPIC_API_KEY (shared with the app); AGENTSPAN_MODEL (default below).
"""
from __future__ import annotations

import os
from contextlib import asynccontextmanager
from typing import Optional

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

from agentspan.agents import Agent, AgentRuntime, tool

MODEL = os.environ.get("AGENTSPAN_MODEL", "anthropic/claude-sonnet-4-6")


# This tool is gated on human approval: the agent pauses here until a human
# approves, and only THEN does this body execute (recording the granted terms).
@tool(approval_required=True)
def grant_exception(account: str, terms: str, approver: str) -> dict:
    """Finalize a pricing exception once the approver has signed off."""
    return {"granted": True, "account": account, "terms": terms, "approver": approver}


coordinator = Agent(
    name="approval-coordinator",
    model=MODEL,
    instructions=(
        "You coordinate deal-desk approvals. Given a parked pricing decision, "
        "call grant_exception with the account, the exact terms (e.g. '25% discount'), "
        "and the required approver. That tool requires human sign-off, so it will "
        "pause for a human. Do not fabricate an approval yourself."
    ),
    tools=[grant_exception],
)

# Keep one long-lived runtime/worker so @tool calls execute and runs stay durable.
runtime: AgentRuntime | None = None
# execution_id -> handle, so Precedent can approve/deny/poll from its own UI.
handles: dict = {}


@asynccontextmanager
async def lifespan(_app: FastAPI):
    global runtime
    runtime = AgentRuntime()
    runtime.__enter__()
    try:
        yield
    finally:
        runtime.__exit__(None, None, None)


app = FastAPI(title="Precedent · AgentSpan sidecar", lifespan=lifespan)


class ApprovalRequest(BaseModel):
    decisionId: str
    summary: str
    approver: str


class ResolveRequest(BaseModel):
    action: str  # "approve" | "reject"
    reason: Optional[str] = None


def _status(handle) -> dict:
    """Best-effort status read across SDK versions."""
    try:
        s = handle.get_status()
        waiting = getattr(s, "is_waiting", None)
        state = getattr(s, "state", None) or getattr(s, "status", None)
        return {"waiting": bool(waiting), "state": str(state) if state else ("waiting" if waiting else "running")}
    except Exception as e:  # noqa: BLE001
        return {"waiting": None, "state": f"unknown ({e})"}


@app.get("/health")
def health():
    # AgentSpan's worker calls the model via litellm using ANTHROPIC_API_KEY from
    # THIS process's env — if it's missing, runs hang in RUNNING and never reach
    # the approval pause. Surface it so it's obvious.
    return {
        "ok": True,
        "model": MODEL,
        "engine": "agentspan",
        "ui": "http://localhost:6767",
        "anthropic_key": bool(os.environ.get("ANTHROPIC_API_KEY")),
    }


@app.post("/durable/approval")
def durable_approval(req: ApprovalRequest):
    """Start a durable run that pauses at grant_exception awaiting human approval."""
    assert runtime is not None
    handle = runtime.start(
        coordinator,
        f"Decision {req.decisionId}: {req.summary}. Required approver: {req.approver}. "
        f"Call grant_exception to finalize — it needs {req.approver}'s sign-off.",
    )
    execution_id = getattr(handle, "execution_id", None) or getattr(handle, "id", "unknown")
    handles[execution_id] = handle
    return {
        "decisionId": req.decisionId,
        "executionId": execution_id,
        "status": "awaiting_approval",
        "approver": req.approver,
        "ui": "http://localhost:6767",
        **_status(handle),
    }


@app.get("/durable/approval/{execution_id}")
def get_status(execution_id: str):
    handle = handles.get(execution_id)
    if not handle:
        raise HTTPException(404, "unknown execution (or sidecar restarted — use the AgentSpan UI)")
    return {"executionId": execution_id, **_status(handle)}


@app.post("/durable/approval/{execution_id}/resolve")
def resolve(execution_id: str, req: ResolveRequest):
    handle = handles.get(execution_id)
    if not handle:
        raise HTTPException(404, "unknown execution (or sidecar restarted — use the AgentSpan UI)")
    if req.action == "approve":
        handle.approve()
        outcome = "approved"
    else:
        handle.reject(req.reason or "Denied by approver")
        outcome = "rejected"
    return {"executionId": execution_id, "outcome": outcome, **_status(handle)}

```

### src/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Space_Grotesk, Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";

const display = Space_Grotesk({ subsets: ["latin"], variable: "--font-display", weight: ["500", "600", "700"] });
const sans = Inter({ subsets: ["latin"], variable: "--font-sans" });
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });

export const metadata: Metadata = {
  title: "Precedent — a context graph for decisions",
  description: "Capture the why behind every pricing exception, approval, and override.",
};

// Set the theme before paint to avoid a flash of the wrong theme.
const themeInit = `(function(){try{var t=localStorage.getItem('precedent-theme')||'dark';document.documentElement.setAttribute('data-theme',t);}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();`;

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" data-theme="dark" className={`${display.variable} ${sans.variable} ${mono.variable}`}>
      <head>
        <script dangerouslySetInnerHTML={{ __html: themeInit }} />
      </head>
      <body className="font-sans antialiased">{children}</body>
    </html>
  );
}

```

### src/app/page.tsx

```typescript
"use client";

import { useCallback, useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { motion, AnimatePresence } from "framer-motion";
import VoiceButton from "@/components/VoiceButton";
import ThemeToggle from "@/components/ThemeToggle";
import type { DecisionEval, DecisionNode, GraphPayload, Integrations, TraceStep } from "@/lib/types";

const DecisionGraph = dynamic(() => import("@/components/DecisionGraph"), { ssr: false });

const EXAMPLE =
  "I need 25% off for Acme Health — their procurement cycles are brutal, " +
  "they've had three SEV-1s, and we did 22% for a comparable health-tech account last quarter.";

const AGENT_LABEL: Record<TraceStep["agent"], string> = {
  orchestrator: "Orchestrator",
  "context-gatherer": "Context Gatherer",
  "precedent-retriever": "Precedent Retriever",
  "policy-evaluator": "Policy Evaluator",
  "approver-router": "Approver Router",
  evaluator: "Evaluator (LLM judge)",
};

const AGENT_TECH: Record<TraceStep["agent"], string[]> = {
  orchestrator: ["Claude"],
  "context-gatherer": ["Browserbase", "CRM/Zendesk/PagerDuty"],
  "precedent-retriever": ["Redis Vector", "LangCache"],
  "policy-evaluator": ["Claude"],
  "approver-router": ["Agent Memory", "Claude"],
  evaluator: ["Arize eval", "Claude"],
};

const STATUS_RING: Record<string, string> = {
  approved: "text-approve border-approve/60",
  denied: "text-deny border-deny/60",
  pending: "text-pending border-pending/60",
  escalated: "text-escalate border-escalate/60",
};

interface DonePayload {
  node: DecisionNode;
  integrations: Integrations;
}

export default function Home() {
  const [text, setText] = useState(EXAMPLE);
  const [requestedBy, setRequestedBy] = useState("Sam Rivera (AE)");
  const [running, setRunning] = useState(false);
  const [interim, setInterim] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [steps, setSteps] = useState<TraceStep[]>([]);
  const [node, setNode] = useState<DecisionNode | null>(null);
  const [integrations, setIntegrations] = useState<Integrations | null>(null);
  const [graph, setGraph] = useState<GraphPayload>({ nodes: [], edges: [] });
  const [highlightId, setHighlightId] = useState<string | null>(null);

  const refreshGraph = useCallback(async () => {
    try {
      const res = await fetch("/api/graph");
      if (res.ok) setGraph(await res.json());
    } catch {
      /* ignore */
    }
  }, []);

  useEffect(() => {
    refreshGraph();
  }, [refreshGraph]);

  const run = async () => {
    setRunning(true);
    setError(null);
    setNode(null);
    setIntegrations(null);
    setSteps([]);
    setHighlightId(null);
    try {
      const res = await fetch("/api/decide", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ rawText: text, requestedBy }),
      });
      if (!res.ok || !res.body) {
        const j = await res.json().catch(() => ({}));
        throw new Error(j.error ?? "decision failed");
      }

      // Read the SSE stream and reveal each agent step as it arrives.
      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buf = "";
      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        buf += decoder.decode(value, { stream: true });
        let idx: number;
        while ((idx = buf.indexOf("\n\n")) >= 0) {
          const frame = buf.slice(0, idx);
          buf = buf.slice(idx + 2);
          const event = frame.match(/^event: (.*)$/m)?.[1];
          const data = frame.match(/^data: (.*)$/m)?.[1];
          if (!event || !data) continue;
          const payload = JSON.parse(data);
          if (event === "step") {
            setSteps((s) => [...s, payload as TraceStep]);
          } else if (event === "done") {
            const d = payload as DonePayload;
            setNode(d.node);
            setIntegrations(d.integrations);
            setHighlightId(d.node.id);
            refreshGraph();
          } else if (event === "error") {
            setError((payload as { error: string }).error);
          }
        }
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : "decision failed");
    } finally {
      setRunning(false);
    }
  };

  const decisionCount = graph.nodes.filter((n) => n.type === "decision").length;
  const showReasoning = running || steps.length > 0 || node;

  return (
    <main className="flex h-screen flex-col bg-bg">
      <header className="flex items-center justify-between border-b border-border px-6 py-3">
        <div className="flex items-baseline gap-3">
          <h1 className="font-display text-[19px] font-semibold tracking-tight text-fg">
            Precedent <span className="text-accent">⟁</span>
          </h1>
          <p className="hidden text-[12px] text-faint sm:block">a context graph for enterprise decisions</p>
        </div>
        <div className="flex items-center gap-4">
          <span className="hidden font-mono text-[10.5px] uppercase tracking-wider text-faint lg:block">
            Claude · Redis Vector · Agent Memory · LangCache · Arize · Band
          </span>
          <ThemeToggle />
        </div>
      </header>

      <div className="grid flex-1 grid-cols-[330px_minmax(380px,440px)_1fr] overflow-hidden">
        {/* 1 — Intake */}
        <section className="flex flex-col gap-3 overflow-y-auto border-r border-border p-5">
          <Field label="Requested by">
            <input
              value={requestedBy}
              onChange={(e) => setRequestedBy(e.target.value)}
              className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-accent"
            />
          </Field>

          <div>
            <div className="mb-1.5 flex items-center justify-between">
              <span className="font-mono text-[10.5px] uppercase tracking-wider text-faint">The as
[truncated — 16389 more characters]
```

### src/app/sentry-example-page/page.tsx

```typescript
"use client";

import * as Sentry from "@sentry/nextjs";
import Head from "next/head";
import { useEffect, useState } from "react";

class SentryExampleFrontendError extends Error {
  constructor(message: string | undefined) {
    super(message);
    this.name = "SentryExampleFrontendError";
  }
}

export default function Page() {
  const [hasSentError, setHasSentError] = useState(false);
  const [isConnected, setIsConnected] = useState(true);

  useEffect(() => {
    Sentry.logger.info("Sentry example page loaded");
    async function checkConnectivity() {
      const result = await Sentry.diagnoseSdkConnectivity();
      setIsConnected(result !== "sentry-unreachable");
    }
    checkConnectivity();
  }, []);

  return (
    <div>
      <Head>
        <title>sentry-example-page</title>
        <meta name="description" content="Test Sentry for your Next.js app!" />
      </Head>

      <main>
        <div className="flex-spacer" />
        <svg
          height="40"
          width="40"
          fill="none"
          xmlns="http://www.w3.org/2000/svg"
          role="img"
          aria-label="Sentry logo"
        >
          <path
            d="M21.85 2.995a3.698 3.698 0 0 1 1.353 1.354l16.303 28.278a3.703 3.703 0 0 1-1.354 5.053 3.694 3.694 0 0 1-1.848.496h-3.828a31.149 31.149 0 0 0 0-3.09h3.815a.61.61 0 0 0 .537-.917L20.523 5.893a.61.61 0 0 0-1.057 0l-3.739 6.494a28.948 28.948 0 0 1 9.63 10.453 28.988 28.988 0 0 1 3.499 13.78v1.542h-9.852v-1.544a19.106 19.106 0 0 0-2.182-8.85 19.08 19.08 0 0 0-6.032-6.829l-1.85 3.208a15.377 15.377 0 0 1 6.382 12.484v1.542H3.696A3.694 3.694 0 0 1 0 34.473c0-.648.17-1.286.494-1.849l2.33-4.074a8.562 8.562 0 0 1 2.689 1.536L3.158 34.17a.611.611 0 0 0 .538.917h8.448a12.481 12.481 0 0 0-6.037-9.09l-1.344-.772 4.908-8.545 1.344.77a22.16 22.16 0 0 1 7.705 7.444 22.193 22.193 0 0 1 3.316 10.193h3.699a25.892 25.892 0 0 0-3.811-12.033 25.856 25.856 0 0 0-9.046-8.796l-1.344-.772 5.269-9.136a3.698 3.698 0 0 1 3.2-1.849c.648 0 1.285.17 1.847.495Z"
            fill="currentcolor"
          />
        </svg>
        <h1>sentry-example-page</h1>

        <p className="description">
          Click the button below, and view the sample error on the Sentry{" "}
          <a
            target="_blank"
            rel="noopener"
            href="https://tru-zjv.sentry.io/issues/?project=4511604306870272"
          >
            Issues Page
          </a>
          . For more details about setting up Sentry,{" "}
          <a
            target="_blank"
            rel="noopener"
            href="https://docs.sentry.io/platforms/javascript/guides/nextjs/"
          >
            read our docs
          </a>
          .
        </p>

        <button
          type="button"
          onClick={async () => {
            Sentry.logger.info("User clicked the button, throwing a sample error");
            await Sentry.startSpan(
              {
                name: "Example Frontend/Backend Span",
                op: "test",
              },
              async () => {
                const res = await fetch("/api/sentry-example-api");
                if (!res.ok) {
                  setHasSentError(true);
                }
              },
            );
            throw new SentryExampleFrontendError(
              "This error is raised on the frontend of the example page.",
            );
          }}
          disabled={!isConnected}
        >
          <span>Throw Sample Error</span>
        </button>

        {hasSentError ? (
          <p className="success">Error sent to Sentry.</p>
        ) : !isConnected ? (
          <div className="connectivity-error">
            <p>
              It looks like network requests to Sentry are being blocked, which
              will prevent errors from being captured. Try disabling your
              ad-blocker to complete the test.
            </p>
          </div>
        ) : (
          <div className="success_placeholder" />
        )}

        <div className="flex-spacer" />
      </main>

      <style>{`
        main {
          display: flex;
          min-height: 100vh;
          flex-direction: column;
          justify-content: center;
          align-items: center;
          gap: 16px;
          padding: 16px;
          font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
        }

        h1 {
          padding: 0px 4px;
          border-radius: 4px;
          background-color: rgba(24, 20, 35, 0.03);
          font-family: monospace;
          font-size: 20px;
          line-height: 1.2;
        }

        p {
          margin: 0;
          font-size: 20px;
        }

        a {
          color: #6341F0;
          text-decoration: underline;
          cursor: pointer;

          @media (prefers-color-scheme: dark) {
            color: #B3A1FF;
          }
        }

        button {
          border-radius: 8px;
          color: white;
          cursor: pointer;
          background-color: #553DB8;
          border: none;
          padding: 0;
          margin-top: 4px;

          & > span {
            display: inline-block;
            padding: 12px 16px;
            border-radius: inherit;
            font-size: 20px;
            font-weight: bold;
            line-height: 1;
            background-color: #7553FF;
            border: 1px solid #553DB8;
            transform: translateY(-4px);
          }

          &:hover > span {
            transform: translateY(-8px);
          }

          &:active > span {
            transform: translateY(0);
          }

          &:disabled {
	            cursor: not-allowed;
	            opacity: 0.6;

	            & > span {
	              transform: translateY(0);
	              border: none
	            }
	          }
        }

        .description {
          text-align: center;
          color: #6E6C75;
          max-width: 500px;
          line-height: 1.5;
          font-size: 20px;

          @media (p
[truncated — 859 more characters]
```

### src/app/api/sentry-example-api/route.ts

```typescript
import * as Sentry from "@sentry/nextjs";
export const dynamic = "force-dynamic";

class SentryExampleAPIError extends Error {
  constructor(message: string | undefined) {
    super(message);
    this.name = "SentryExampleAPIError";
  }
}

// A faulty API route to test Sentry's error monitoring
export function GET() {
  Sentry.logger.info("Sentry example API called");
  throw new SentryExampleAPIError(
    "This error is raised on the backend called by the example page.",
  );
}

```

### src/app/api/graph/route.ts

```typescript
import { NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { buildGraph, ensureIndex } from "@/lib/redis";

export const runtime = "nodejs";

export async function GET() {
  try {
    await ensureIndex();
    const graph = await buildGraph();
    return NextResponse.json(graph);
  } catch (e) {
    Sentry.captureException(e);
    console.error("[/api/graph] error:", e);
    return NextResponse.json(
      { error: e instanceof Error ? e.message : "graph failed" },
      { status: 500 }
    );
  }
}

```

[77 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]