Project workspace (Claude Agent SDK)
PremiumThe person's answer to a permission request from the agent: { id, approved, reason? }. The workspace's own tools never ask; yours may.
Preview
It runs with a server and an agent, so there’s no preview here: install it to run it in your app.
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/project-workspace-claude
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: @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
<POST>
No props.
Also takes the props of Request.
<GET>
Download a project's file: ?project=P-1&file=F-1. Files are markdown; served as an attachment with a plain-text type, so the browser saves it rather than rendering anything in it.
No props.
Also takes the props of Request.
<POST>
The project's agent, on the Claude Agent SDK: { prompt, sessionId?, projectId }. The SDK's own cost for the run (its share of the session's running total) is recorded when it ends, and the next run is refused at the limit. Set ANTHROPIC_API_KEY. Runs on one Node server, not spread over instances.
No props.
Also takes the props of Request.
<GET>
The rail, and with ?project=P-1 that project's thread and panel: { rail, project? }.
No props.
Also takes the props of Request.
<POST>
A person's action: { action: "create", title } or { action: "decide", projectId, decisionId, choiceId }.
No props.
Also takes the props of Request.
<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.
| Prop | Type | Default | Description |
|---|---|---|---|
| projectIdrequired | string | null | — | The open project, e.g. "P-1"; without one, the first project that needs you (or the newest) opens. |
| chatrequired | WorkspaceChat | — | — |
| defaultWidth | number | undefined | — | The width to lay out for before the frame is measured (the server, tests); AppLayout's `defaultWidth`. |
Functions
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(db: WorkspaceDb)The workspace on Postgres through Drizzle. Run the migrations in ./migrations (or drizzle-kit) before using it.
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()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(tx: WorkspaceTx)Load the demo into a WorkspaceTx, in id order. Used by the in-memory store and by ./db/seed.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(request: Request)Who is signed in. Demo only: a cookie the template's people switcher sets. Replace with your auth.
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(repo: WorkspaceRepo)Use your own storage (or a database you opened yourself). Call it once, before the routes run.
workspaceRepo()The storage in use: whatever setWorkspaceRepo() was given, else Postgres when DATABASE_URL is set, else memory (with the demo).
resetStore()Tests and demos only: back to the demo projects.
agentContext(projectId: string)The project as the agent should know it at the start of a run.
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(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(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(projectId: string, input: In<"setPlan">)updateStep(projectId: string, input: In<"updateStep">)saveFile(projectId: string, input: In<"saveFile">)askDecision(projectId: string, input: In<"askDecision">)finish(projectId: string, input: In<"finish">)createProject(title: string, viewer: string)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()The rail: every project with one live line, newest first.
projectView(projectId: string)One project, everything its thread and panel show.
fileForDownload(projectId: string, fileId: string)A file's text for download, by id on its project.
decisionSummary(d: Pick<DecisionRecord, "question" | "choices">)One line for a card's plain-text summary (copy, and a fallback).
Types
- TeamRole
keyof typeof team- WorkspaceDb
PgDatabase<PgQueryResultHKT, any>- ProjectState
- Talking: no plan yet. Working: the plan is running. Waiting: a decision waits on the person. Finished: handed over.
"talking" | "working" | "waiting" | "finished"- StepStatus
"idle" | "running" | "succeeded" | "failed"- EventKind
"created" | "plan" | "step" | "file" | "decision" | "decided" | "limit" | "result"- ProjectRecord
{ 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
{ 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.
{ id: string; projectId: string; name: string; text: string; final: boolean; at: string }- DecisionChoiceRecord
{ 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.
{ 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.
{ id: string; projectId: string; kind: EventKind; text: string; at: string }- ResultRecord
- What the agent handed over: its answer, and when. One per project.
{ projectId: string; answer: string; at: string }- NewProject
Omit<ProjectRecord, "id">- NewStep
Omit<StepRecord, "id" | "projectId">- NewFile
Omit<FileRecord, "id">- NewDecision
Omit<DecisionRecord, "id" | "state" | "chosen" | "by" | "decidedAt">- NewEvent
Omit<EventRecord, "id">- RailRow
{ id: string; title: string; state: ProjectState; updatedAt: string; detail?: string | undefined }- ProjectView
{ 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.
{ messages: MessageData[] composer: React.ComponentProps<typeof Composer> error?: string | undefined }- Snapshot
{ rail: RailRow[]; project?: ProjectView | undefined }