# Project workspace (Claude Agent SDK)

A workspace where each project is a conversation with an agent team: a rail of projects grouped by what needs you, the thread, and a panel with the project's overview, plan, files and history. The agent plans, works and hands over files, but it stops for the person at the spending limit and on any decision it asks for, and the receipt shows only what was recorded.

- Registry name: `project-workspace-claude` (`@candor/project-workspace-claude`)
- Tier: Premium. Installing it needs a Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/project-workspace-claude

## Install

```bash
npx candor-ui add project-workspace-claude
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/project-workspace-claude`.
It brings in: agent-activity, app-layout, approval-request, artifact-panel, button, chat, claude-agent-server, command-palette, composer, file-list, message, page-state, plan, relative-time, result-card, session-list, side-panel, status, status-dot, team, thread, thread-event, usage-meters, use-claude-agent, use-shortcut, work.
npm packages: @anthropic-ai/claude-agent-sdk@^0.3.318, drizzle-orm@^0.45.4, lucide-react@^1.67.0, postgres@^4.0.0, zod@^4.6.7.

It installs under your app's `app/` folder and runs with a server and an agent: set `ANTHROPIC_API_KEY`, and read the comment at the top of its `store.ts` for storage and auth. Its files:

- `app/project-workspace/agent.ts`
- `app/project-workspace/api/approve/route.ts`
- `app/project-workspace/api/file/route.ts`
- `app/project-workspace/api/route.ts`
- `app/project-workspace/api/workspace/route.ts`
- `app/project-workspace/db/connect.ts`
- `app/project-workspace/db/drizzle-repo.ts`
- `app/project-workspace/db/schema.ts`
- `app/project-workspace/db/seed.ts`
- `app/project-workspace/page.tsx`
- `app/project-workspace/repo.ts`
- `app/project-workspace/store.ts`
- `app/project-workspace/workspace.tsx`

## <WorkspacePage>

No props.

## <Workspace>

The workspace: projects in the rail (needs you first), the open project's thread with the agent, and a fixed panel with its Overview, Plan, Files and History. A decision the agent (or the spending limit) stops for is answered in the thread; the result is handed over there too, answer first.

Props:

- `projectId: string | null`: The open project, e.g. "P-1"; without one, the first project that needs you (or the newest) opens.
- `chat: WorkspaceChat`
- `defaultWidth?: number | undefined`: The width to lay out for before the frame is measured (the server, tests); AppLayout's `defaultWidth`.

## Functions

### connectWorkspaceDb

```ts
connectWorkspaceDb(url: string)
```

Open the workspace's database from a Postgres URL with the postgres-js driver. Swap the driver here (node-postgres, Neon's websocket Pool, …) if your app already uses another; it must support interactive transactions.

### drizzleWorkspaceRepo

```ts
drizzleWorkspaceRepo(db: WorkspaceDb)
```

The workspace on Postgres through Drizzle. Run the migrations in ./migrations (or drizzle-kit) before using it.

### seedWorkspace

```ts
seedWorkspace(db: WorkspaceDb)
```

Load the demo projects into an empty database. It does nothing when any project exists, so it is safe to run on every start. Run it after migrating, e.g. `await seedWorkspace(connectWorkspaceDb(process.env.DATABASE_URL!))`.

### demoWorkspaceData

```ts
demoWorkspaceData()
```

The demo's projects, for the in-memory store and for seeding a database (./db/seed.ts). Ids are assigned in this order (P-1…P-4; S-1…; F-1…; D-1; E-1…). Times are relative to now.

### loadDemo

```ts
loadDemo(tx: WorkspaceTx)
```

Load the demo into a WorkspaceTx, in id order. Used by the in-memory store and by ./db/seed.ts.

### memoryWorkspaceRepo

```ts
memoryWorkspaceRepo()
```

In this server process's memory: gone on restart and not shared between instances. Transactions run one at a time, and a transaction that throws is rolled back to the state it started from.

### viewerOf

```ts
viewerOf(request: Request)
```

Who is signed in. Demo only: a cookie the template's people switcher sets. Replace with your auth.

### costOf

```ts
costOf(model: string, tokens: { input?: number | undefined; output?: number | undefined })
```

What a run cost at list price, or null when the model or the token counts are unknown (then nothing is recorded).

### setWorkspaceRepo

```ts
setWorkspaceRepo(repo: WorkspaceRepo)
```

Use your own storage (or a database you opened yourself). Call it once, before the routes run.

### workspaceRepo

```ts
workspaceRepo()
```

The storage in use: whatever setWorkspaceRepo() was given, else Postgres when DATABASE_URL is set, else memory (with the demo).

### resetStore

```ts
resetStore()
```

Tests and demos only: back to the demo projects.

### agentContext

```ts
agentContext(projectId: string)
```

The project as the agent should know it at the start of a run.

### agentBlocked

```ts
agentBlocked(projectId: string)
```

Why the agent can't work on this project now, or null when it can. At the limit, this is also where the limit decision opens, so the person sees the question the moment the run is refused.

### recordClaudeSpend

```ts
recordClaudeSpend(projectId: string, sessionId: string, totalUsd: number | null | undefined, resumed: boolean)
```

Record a Claude Agent SDK run's cost. The SDK's `total_cost_usd` is the session's running total (a resumed session starts from its earlier turns), so only the change since the last total seen for that session is added. A resumed session this server never saw a total for records nothing, rather than counting earlier turns again.

### recordSpend

```ts
recordSpend(projectId: string, costUsd: number | null | undefined)
```

Record what a run cost. Called after each run; a run whose cost isn't known records nothing.

### setPlan

```ts
setPlan(projectId: string, input: In<"setPlan">)
```

### updateStep

```ts
updateStep(projectId: string, input: In<"updateStep">)
```

### saveFile

```ts
saveFile(projectId: string, input: In<"saveFile">)
```

### askDecision

```ts
askDecision(projectId: string, input: In<"askDecision">)
```

### finish

```ts
finish(projectId: string, input: In<"finish">)
```

### createProject

```ts
createProject(title: string, viewer: string)
```

### decide

```ts
decide(projectId: string, decisionId: string, choiceId: string, viewer: string)
```

The person answers the open decision. Recorded once; a limit decision raises the limit or finishes the project.

### rail

```ts
rail()
```

The rail: every project with one live line, newest first.

### projectView

```ts
projectView(projectId: string)
```

One project, everything its thread and panel show.

### fileForDownload

```ts
fileForDownload(projectId: string, fileId: string)
```

A file's text for download, by id on its project.

### decisionSummary

```ts
decisionSummary(d: Pick<DecisionRecord, "question" | "choices">)
```

One line for a card's plain-text summary (copy, and a fallback).

## Types

### TeamRole

```ts
keyof typeof team
```

### WorkspaceDb

```ts
PgDatabase<PgQueryResultHKT, any>
```

### ProjectState

Talking: no plan yet. Working: the plan is running. Waiting: a decision waits on the person. Finished: handed over.

```ts
"talking" | "working" | "waiting" | "finished"
```

### StepStatus

```ts
"idle" | "running" | "succeeded" | "failed"
```

### EventKind

```ts
"created" | "plan" | "step" | "file" | "decision" | "decided" | "limit" | "result"
```

### ProjectRecord

```ts
{ id: string title: string state: ProjectState /** What the person allows this project to spend, in USD. */ limitUsd: number /** What its runs have cost so far, in USD, as recorded after each run. */ spentUsd: number /** The agent's own estimate of the total, as it gave it; never computed here. */ estimateUsd?: number | undefined createdAt: string updatedAt: string }
```

### StepRecord

```ts
{ id: string; projectId: string; ord: number; title: string; role: TeamRole; status: StepStatus; note?: string | undefined }
```

### FileRecord

A document the agent wrote, as markdown text. `final` once it is part of the handed-over result.

```ts
{ id: string; projectId: string; name: string; text: string; final: boolean; at: string }
```

### DecisionChoiceRecord

```ts
{ id: string; label: string; detail?: string | undefined }
```

### DecisionRecord

A question the work stops for: from the agent ("agent"), or the spending limit ("limit"). One open per project.

```ts
{ id: string projectId: string kind: "agent" | "limit" question: string facts: { label: string; value: string }[] continues?: string | undefined choices: DecisionChoiceRecord[] recommended?: string | undefined /** For a limit decision: the limit "raise" moves to. */ raiseTo?: number | undefined state: "open" | "decided" chosen?: string | undefined by?: string | undefined at: string decidedAt?: str
```

### EventRecord

One line of the project's history. Append-only.

```ts
{ id: string; projectId: string; kind: EventKind; text: string; at: string }
```

### ResultRecord

What the agent handed over: its answer, and when. One per project.

```ts
{ projectId: string; answer: string; at: string }
```

### NewProject

```ts
Omit<ProjectRecord, "id">
```

### NewStep

```ts
Omit<StepRecord, "id" | "projectId">
```

### NewFile

```ts
Omit<FileRecord, "id">
```

### NewDecision

```ts
Omit<DecisionRecord, "id" | "state" | "chosen" | "by" | "decidedAt">
```

### NewEvent

```ts
Omit<EventRecord, "id">
```

### RailRow

```ts
{ id: string; title: string; state: ProjectState; updatedAt: string; detail?: string | undefined }
```

### ProjectView

```ts
{ project: ProjectRecord steps: StepRecord[] files: FileRecord[] /** The decision waiting on the person, if any. */ decision?: DecisionRecord | undefined /** Decisions already answered, oldest first. */ decided: DecisionRecord[] events: { id: string; kind: EventKind; text: string; at: string }[] result?: ResultRecord & { receipt: { label: string; value: string }[] } | undefined }
```

### WorkspaceChat

The chat state from useCandorChat (AI SDK) or useClaudeAgent (Claude Agent SDK); the screen works with either.

```ts
{ messages: MessageData[] composer: React.ComponentProps<typeof Composer> error?: string | undefined }
```

### Snapshot

```ts
{ rail: RailRow[]; project?: ProjectView | undefined }
```

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider` (fixed strings) and `TermsProvider` (nouns); entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
