Files
PaperClipAI/server/src/routes/status-cards.ts
T
DottaandClaude Opus 5.5 2ea1a4f4eb fix(chat): tell chat agents what a status card is and to reply now after queuing one (#20230)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agent Chat lets a person talk to an agent in a standing conversation
> - Status Cards is an experimental feature: a saved card on the
company's Status page that a summarizer agent keeps up to date
> - A chat agent cannot read the experimental settings, so nothing it
reads says that status cards exist, how to create one, or where a card
lives
> - When a person asks for a status card, the agent writes a Markdown
task document instead. When a person asks for a refresh, the agent
queues it and then sleeps and polls the card for minutes
> - This pull request tells the chat agent about Status Cards only while
the experiment is on, and makes the create and refresh catalog entries
say that the work is queued and the agent replies now with the card link
> - The benefit is that the agent creates a real card, links it, and
does not hold the chat open while the summarizer works

## Linked Issues or Issue Description

**What happened**
With Status Cards enabled, a person asked the CEO agent "Make me a
status card showing the launch's biggest blockers." The agent's
reasoning treated "status card" as a format. It never searched for the
feature and saved a Markdown task document. In a second chat, a person
asked "Refresh the launch status card now." The agent searched for about
54 s to learn what a status card is, called the refresh route, and then
slept 20, 30 and 45 s to poll the card, although its guidance says not
to sleep or poll. The refresh response also did not include the card's
page link. Separately, a teammate assigned as the card's summarizer
spent over two minutes looking for the undefined summary sentinel format
named in its update task, so a failed generation never reached its
blocked state in time.

**Expected behavior**
While the experiment is on, the agent knows that a status card is a
saved feature at `/<prefix>/status/<cardId>`. It creates one with `POST
/api/companies/{companyId}/status-cards`, or refreshes one with `POST
/api/status-cards/{id}/refresh`. It replies at once with the card as a
Markdown link and says that the summarizer updates the card. When the
experiment is off, the agent sees no status card guidance.

**Steps to reproduce**
1. Enable Status Cards in Instance Settings > Experimental.
2. Open a chat with the CEO agent.
3. Send "Make me a status card showing the launch's biggest blockers."
4. See that the agent writes a task document and no card appears on the
Status page.

## What Changed

- Add `STATUS_CARDS_CHAT_DIRECTIVE` in
`server/src/services/agent-conversations.ts`. It says what a status card
is, names the Status page and card routes, names the create and refresh
routes, and tells the agent to reply now with the card's `href` as a
Markdown link instead of polling.
- Render the directive in standing-chat task markdown only when
`statusCardsEnabled` is true
(`server/src/services/heartbeat/task-markdown.ts`). Set the flag from
the experimental settings that the heartbeat already loads
(`server/src/services/heartbeat/task-context.ts`). No new query runs
before the turn starts.
- Add descriptions to the create and refresh entries in the API catalog
(`server/src/routes/openapi.ts`). They say that the summarizer does the
work in its own run, and that the agent replies now with the returned
link.
- Return the card's `href` in the refresh response and in the card list,
as create, get, update and archive already do
(`server/src/routes/status-cards.ts`). An agent that reuses a listed
card can then link it.
- Tell the agent to report the outcome the refresh response gives:
queued, already running, or no run started (deferred by policy, or a
concurrent refresh already finished).
- Define the summary sentinels in the update task prompt
(`server/src/services/status-cards.ts`). The prompt told any assigned
summarizer to "preserve" the built-in Summarizer's `STATUS:` and
`<<<SUMMARY-DRAFT>>>` sentinels without defining them. A non-built-in
summarizer searched for the format for minutes. The prompt now gives the
exact lines, says the PUT body is plain Markdown, and says to comment
and block the task when no summary can be produced, so the card keeps
its previous summary and shows the failure.
- Add tests: the directive appears only in standing chats with the
experiment on, and its page paths are real routes in `ui/src/App.tsx`;
the catalog descriptions; the refresh response `href`.

## Verification

- `cd server && npx vitest run
src/services/heartbeat/task-markdown.test.ts
src/__tests__/openapi-routes.test.ts src/__tests__/status-cards.test.ts
src/__tests__/agent-conversations.test.ts` passes (all pass;
`status-cards.test.ts` also checks the update task prompt).
- `npx tsc --noEmit -p server` reports no errors in the changed files.
- End to end: the `status-cards.create-card` and
`status-cards.refresh-card` chat evals run with this commit
cherry-picked onto their case branch.

## Risks

- Low risk. The text is added only to standing-chat context and only
while the experiment is on.
- The refresh response gains one field (`card.href`). Existing clients
ignore unknown fields.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code
execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:39:01 -06:00

343 lines
15 KiB
TypeScript

import { Router, type Request } from "express";
import { companies, type Db } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import {
createStatusCardSchema,
listStatusCardsQuerySchema,
patchStatusCardSchema,
refreshStatusCardSchema,
STATUS_CARD_AGENT_MAX_INTEREST_PROMPT_LENGTH,
writeStatusCardQuerySchema,
writeStatusCardSummarySchema,
} from "@paperclipai/shared";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { authorizationDeniedDetails, issueReadSqlCondition } from "../services/authorization.js";
import { accessService, heartbeatService, instanceSettingsService, issueService, logActivity, statusCardService } from "../services/index.js";
import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "../services/issue-assignment-wakeup.js";
import { assertCompanyAccess, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js";
// Deadline provenance belongs to scheduling, not the public card contract.
function publicCard<T extends { nextEvalReason?: unknown }>(card: T) {
const { nextEvalReason: _reason, ...result } = card;
return result;
}
export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWakeupDeps } = {}) {
const router = Router();
const access = accessService(db);
const settings = instanceSettingsService(db);
const service = statusCardService(db);
const issueSvc = issueService(db);
const heartbeat = opts.heartbeat ?? heartbeatService(db);
// People view, restore and refresh a card on the company's Status page. Single-card responses carry that
// route so an agent can give the person a usable link instead of an API instruction.
async function companyIssuePrefix(companyId: string) {
const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId)).limit(1);
return company?.issuePrefix ?? null;
}
function withHref<T extends { id: string }>(card: T, issuePrefix: string | null) {
return issuePrefix ? { ...card, href: `/${issuePrefix}/status/${card.id}` } : card;
}
async function assertStatusCardsEnabled() {
const experimental = await settings.getExperimental();
if (experimental.enableStatusCards !== true) throw notFound("Status cards are not enabled");
}
async function assertCanMutate(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
const decision = await access.decide({
actor: req.actor,
action: "tasks:assign",
resource: {
type: "issue",
companyId,
issueId: null,
projectId: null,
parentIssueId: null,
assigneeAgentId: null,
assigneeUserId: null,
},
});
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
async function assertCanManageCard(req: Request, card: { companyId: string; createdByAgentId: string | null }) {
await assertCanMutate(req, card.companyId);
if (req.actor.type === "agent" && card.createdByAgentId !== req.actor.agentId) {
throw forbidden("Agents can only manage status cards they authored");
}
}
function assertAgentPromptLimit(req: Request, interestPrompt: string | undefined) {
if (
req.actor.type === "agent" &&
interestPrompt !== undefined &&
interestPrompt.length > STATUS_CARD_AGENT_MAX_INTEREST_PROMPT_LENGTH
) {
throw unprocessable(
`Agent-authored status card prompts cannot exceed ${STATUS_CARD_AGENT_MAX_INTEREST_PROMPT_LENGTH} characters`,
);
}
}
async function logMutation(req: Request, companyId: string, action: string, cardId: string, details?: Record<string, unknown>) {
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
action,
entityType: "status_card",
entityId: cardId,
agentId: actor.agentId,
runId: actor.runId,
details,
});
}
async function enqueueCompile(req: Request, cardId: string) {
const actor = getActorInfo(req);
const result = await service.requestCompile(cardId, {
agentId: actor.actorType === "agent" ? actor.actorId : null,
userId: actor.actorType === "user" ? actor.actorId : null,
});
if (!result.alreadyGenerating) {
try {
await queueIssueAssignmentWakeup({
heartbeat,
issue: result.generatingIssue,
reason: "status_card_compile_assigned",
mutation: "status_card.compile_requested",
contextSource: "status_card_compile",
requestedByActorType: actor.actorType === "agent" ? "agent" : "user",
requestedByActorId: actor.actorId,
taskKey: `status-card:${cardId}`,
rethrowOnError: true,
});
} catch (error) {
await issueSvc.update(result.generatingIssue.id, { status: "cancelled" });
throw error;
}
}
return result;
}
async function enqueueRefresh(req: Request, cardId: string, full: boolean, trigger: "manual" | "restore" = "manual") {
const actor = getActorInfo(req);
const result = await service.requestRefresh(cardId, {
full,
trigger,
actor: {
agentId: actor.actorType === "agent" ? actor.actorId : null,
userId: actor.actorType === "user" ? actor.actorId : null,
},
});
if (result.enqueued && result.generatingIssue && !result.alreadyGenerating) {
try {
await queueIssueAssignmentWakeup({
heartbeat,
issue: result.generatingIssue,
reason: "status_card_update_assigned",
mutation: "status_card.refresh_requested",
contextSource: "status_card_update",
requestedByActorType: actor.actorType === "agent" ? "agent" : "user",
requestedByActorId: actor.actorId,
taskKey: `status-card:${cardId}`,
rethrowOnError: true,
});
} catch (error) {
await issueSvc.update(result.generatingIssue.id, { status: "cancelled" });
throw error;
}
}
return result;
}
router.get("/companies/:companyId/status-cards", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
await assertStatusCardsEnabled();
const query = listStatusCardsQuerySchema.parse(req.query);
const issuePrefix = await companyIssuePrefix(companyId);
res.json((await service.list(companyId, query.archived, {
issueReadCondition: await issueReadSqlCondition(db, req.actor),
})).map((card) => withHref(publicCard(card), issuePrefix)));
});
router.post("/companies/:companyId/status-cards", validate(createStatusCardSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertStatusCardsEnabled();
await assertCanMutate(req, companyId);
assertAgentPromptLimit(req, req.body.interestPrompt);
const actor = getActorInfo(req);
// Resolve the link before creating the card, so a failed lookup leaves nothing behind.
const issuePrefix = await companyIssuePrefix(companyId);
const card = await service.create(companyId, req.body, {
agentId: actor.actorType === "agent" ? actor.actorId : null,
userId: actor.actorType === "user" ? actor.actorId : null,
});
// Only a failure to queue the compile removes the new card. Once the compile is queued, the card
// and its generation task exist together, so later failures must not delete the card alone.
let compile: Awaited<ReturnType<typeof enqueueCompile>>;
try {
compile = await enqueueCompile(req, card.id);
} catch (error) {
await service.remove(card.id);
throw error;
}
await logMutation(req, companyId, "status_card.created", card.id, { state: card.state });
res.status(201).json(withHref(publicCard(compile.card), issuePrefix));
});
router.get("/status-cards/:id", async (req, res) => {
await assertStatusCardsEnabled();
const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found");
if (!card) return;
res.json(withHref(publicCard(await service.hydrate(card, {
issueReadCondition: await issueReadSqlCondition(db, req.actor),
})), await companyIssuePrefix(card.companyId)));
});
router.patch("/status-cards/:id", validate(patchStatusCardSchema), async (req, res) => {
await assertStatusCardsEnabled();
const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found");
if (!card) return;
await assertCanManageCard(req, card);
assertAgentPromptLimit(req, req.body.interestPrompt);
const actor = getActorInfo(req);
const updated = await service.update(card, req.body, {
agentId: actor.actorType === "agent" ? actor.actorId : null,
userId: actor.actorType === "user" ? actor.actorId : null,
});
const compile = req.body.interestPrompt !== undefined ? await enqueueCompile(req, card.id) : null;
const restore = req.body.archived === false && card.archivedAt && updated.queries.length > 0 && !updated.generatingIssueId
? await enqueueRefresh(req, card.id, true, "restore")
: null;
await logMutation(req, card.companyId, "status_card.updated", card.id, {
fields: Object.keys(req.body),
archived: Boolean(updated.archivedAt),
});
res.json(withHref(publicCard(compile?.card ?? restore?.card ?? updated), await companyIssuePrefix(card.companyId)));
});
router.delete("/status-cards/:id", async (req, res) => {
await assertStatusCardsEnabled();
const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found");
if (!card) return;
await assertCanManageCard(req, card);
await service.remove(card.id);
await logMutation(req, card.companyId, "status_card.deleted", card.id);
res.status(204).send();
});
router.get("/status-cards/:id/updates", async (req, res) => {
await assertStatusCardsEnabled();
const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found");
if (!card) return;
res.json(await service.listUpdates(card.id));
});
router.get("/status-cards/:id/summary-revisions", async (req, res) => {
await assertStatusCardsEnabled();
const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found");
if (!card) return;
res.json(await service.listSummaryRevisions(card));
});
router.post("/status-cards/:id/recompile", async (req, res) => {
await assertStatusCardsEnabled();
const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found");
if (!card) return;
await assertCanManageCard(req, card);
const result = await enqueueCompile(req, card.id);
await logMutation(req, card.companyId, "status_card.recompile_requested", card.id, {
generatingIssueId: result.generatingIssue.id,
alreadyGenerating: result.alreadyGenerating,
});
res.status(result.alreadyGenerating ? 200 : 202).json({ ...result, card: publicCard(result.card) });
});
router.post("/status-cards/:id/refresh", validate(refreshStatusCardSchema), async (req, res) => {
await assertStatusCardsEnabled();
const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found");
if (!card) return;
await assertCanManageCard(req, card);
const result = await enqueueRefresh(req, card.id, req.body.full);
await logMutation(req, card.companyId, "status_card.refresh_requested", card.id, {
full: req.body.full,
generatingIssueId: result.generatingIssue?.id ?? null,
alreadyGenerating: result.alreadyGenerating,
enqueued: result.enqueued,
});
res.status(result.enqueued && !result.alreadyGenerating ? 202 : 200).json({
...result,
card: withHref(publicCard(result.card), await companyIssuePrefix(card.companyId)),
});
});
router.get("/status-cards/:id/dry-run", async (req, res) => {
await assertStatusCardsEnabled();
const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found");
if (!card) return;
const decision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId: card.companyId },
});
if (!decision.allowed) {
throw forbidden("Status-card dry-run is outside this actor's low-trust authorization boundary", authorizationDeniedDetails(decision));
}
const issueReadCondition = await issueReadSqlCondition(db, req.actor);
res.json({
cardId: card.id,
queryVersion: card.queryVersion,
queries: await service.dryRun(card, { issueReadCondition }),
mentionedIssues: await service.listMentionedIssues(card, { issueReadCondition }),
});
});
router.put("/status-cards/:id/query", validate(writeStatusCardQuerySchema), async (req, res) => {
await assertStatusCardsEnabled();
const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found");
if (!card) return;
if (!hasCompanyAccess(req, card.companyId)) throw notFound("Status card not found");
assertCompanyAccess(req, card.companyId);
const actor = getActorInfo(req);
const updated = await service.writeQuery(card.id, req.body, {
agentId: actor.actorType === "agent" ? actor.actorId : null,
runId: actor.runId ?? null,
});
await logMutation(req, card.companyId, "status_card.query_written", card.id, {
queryVersion: updated.queryVersion,
generationIssueId: req.body.generationIssueId,
changeSummary: req.body.changeSummary,
});
res.json(publicCard(updated));
});
router.put("/status-cards/:id/summary", validate(writeStatusCardSummarySchema), async (req, res) => {
await assertStatusCardsEnabled();
const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found");
if (!card) return;
if (!hasCompanyAccess(req, card.companyId)) throw notFound("Status card not found");
assertCompanyAccess(req, card.companyId);
const actor = getActorInfo(req);
const result = await service.writeSummary(card.id, req.body, {
agentId: actor.actorType === "agent" ? actor.actorId : null,
runId: actor.runId ?? null,
});
await logMutation(req, card.companyId, "status_card.summary_written", card.id, {
queryVersion: result.card.queryVersion,
generationIssueId: req.body.generationIssueId,
documentId: result.document.id,
changeSummary: req.body.changeSummary,
});
res.json({ ...result, card: publicCard(result.card) });
});
return router;
}