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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-09 05:39:01 -06:00
1 parent 9db7da444f
commit 2ea1a4f4eb
9 files changed
+62 -5

No files matched your search

@@ -594,6 +594,16 @@ describe("openapi routes", () => {
expect(patch.description).toContain("Restoring (archived: false) starts a fresh full summary");
});
it("tells agents that creating or refreshing a status card only queues the summarizer, so they reply now with its link", async () => {
const res = await request(createApp()).get("/api/openapi.json");
const create = res.body.paths["/api/companies/{companyId}/status-cards"].post;
expect(create.description).toContain("The response is returned before any summary exists");
expect(create.description).toContain("reply now with the returned href as a Markdown link");
const refresh = res.body.paths["/api/status-cards/{id}/refresh"].post;
expect(refresh.description).toContain("Queues a refresh; it does not return a new summary");
expect(refresh.description).toContain("Do not sleep or poll for it: reply now that the refresh is queued, with the returned card.href");
});
it("documents exact failed-run selection and durable accepted retry responses", async () => {
const res = await request(createApp()).get("/api/openapi.json");
const wake = res.body.paths["/api/agents/{id}/wakeup"].post;
@@ -281,6 +281,8 @@ describeEmbeddedPostgres("status card routes", () => {
// The person's restore route travels with the archive receipt, so an agent can link it.
expect(archived.body).toMatchObject({ archivedAt: expect.any(String), generatingIssueId: null, href: `/${company.issuePrefix}/status/${created.body.id}` });
expect((await request(app).get(`/api/status-cards/${created.body.id}`)).body.href).toBe(`/${company.issuePrefix}/status/${created.body.id}`);
// A card found by listing carries the same link, so an agent reusing it can reply with it.
expect((await request(app).get(`/api/companies/${company.id}/status-cards?archived=true`)).body[0]?.href).toBe(`/${company.issuePrefix}/status/${created.body.id}`);
expect(await db.select().from(issues).where(eq(issues.id, created.body.generatingIssueId)).then((rows) => rows[0]?.status)).toBe("cancelled");
expect((await request(app).get(`/api/companies/${company.id}/status-cards`)).body).toEqual([]);
expect((await request(app).get(`/api/companies/${company.id}/status-cards?archived=true`)).body).toHaveLength(1);
@@ -498,6 +500,13 @@ describeEmbeddedPostgres("status card routes", () => {
const res = await request(app).post(`/api/status-cards/${card.id}/refresh`).send({ full: true });
expect(res.status).toBe(202);
expect(res.body.enqueued).toBe(true);
// The queued refresh carries the card's page, so the requester can link it instead of waiting for the summary.
expect(res.body.card.href).toBe(`/${company.issuePrefix}/status/${card.id}`);
// Any assigned summarizer, not only the built-in one, gets the full write-back and failure contract inline.
const [generation] = await db.select().from(issues).where(eq(issues.id, res.body.generatingIssue.id));
expect(generation?.description).toContain("put the final Markdown between a `<<<SUMMARY-DRAFT>>>` line and a `<<<END-SUMMARY-DRAFT>>>` line");
expect(generation?.description).toContain("there is no other format to look up");
expect(generation?.description).toContain("comment the reason on this task and set it to blocked");
});
it("requires tasks:assign for mutations", async () => {
+8
View File
@@ -3444,6 +3444,10 @@ registry.registerPath({
path: "/api/companies/{companyId}/status-cards",
tags: ["status-cards"],
summary: "Create a status card",
description: "Creates a saved status card on the company's Status page: a live summary of the tasks the interestPrompt describes. "
+ "agentId names the agent that writes its summary; without it the built-in Summarizer is used when configured. "
+ "The response is returned before any summary exists: the card's summarizer compiles its query and writes the first summary in its own run. "
+ "Do not wait or poll for it; reply now with the returned href as a Markdown link, which opens the card at /<prefix>/status/<cardId>.",
request: {
params: z.object({ companyId: z.string() }),
body: jsonBody(createStatusCardSchema),
@@ -3497,6 +3501,10 @@ registerCurrentRoute({
path: "/api/status-cards/{id}/refresh",
tags: ["status-cards"],
summary: "Refresh a status card",
description: "Queues a refresh; it does not return a new summary. The card's summarizer agent writes the update in its own generation run, after which the card's lastGeneratedAt and summary change. "
+ "Do not sleep or poll for it: reply now that the refresh is queued, with the returned card.href as a Markdown link where the person sees the update. "
+ "alreadyGenerating means an earlier refresh is still running. enqueued false without alreadyGenerating means this request started no run: "
+ "if card.lastGeneratedAt is newer than before your request, a concurrent refresh already finished; otherwise the card's refresh policy deferred it, and card.state says why (such as paused_budget or paused_hours).",
body: refreshStatusCardSchema,
});
+6 -2
View File
@@ -161,9 +161,10 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
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(publicCard));
})).map((card) => withHref(publicCard(card), issuePrefix)));
});
router.post("/companies/:companyId/status-cards", validate(createStatusCardSchema), async (req, res) => {
@@ -271,7 +272,10 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
alreadyGenerating: result.alreadyGenerating,
enqueued: result.enqueued,
});
res.status(result.enqueued && !result.alreadyGenerating ? 202 : 200).json({ ...result, card: publicCard(result.card) });
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) => {
@@ -130,6 +130,9 @@ For status updates, distinguish recorded task status from active execution and v
Keep discussion here and leave the conversation available for the next message. Link handed-off tasks in your reply; do not make this conversation blocked by their completion or wait for them. Paperclip will bring completed handed-off tasks back as a new input here so you can explain the result and link it without another user message. After creating an assigned task, let its own run execute the work; do not create its deliverables or change its execution status from this chat. Reply normally and end your turn; Paperclip manages the conversation waiting state. Do not change its status, create a review confirmation just to finish a reply, mark it complete, or poll for another reply. An accepted plan authorizes handoff to execution tasks, never implementation on this conversation. Honor normal approvals. Ask mode is non-mutating. Plan mode supports research and writing/revising the plan; hand off for execution only through the normal authorized workflow.`;
/** Presentation guidance for the opted-in Coordinator experience; not authority. */
/** Rendered only while the instance enables the Status Cards experiment. */
export const STATUS_CARDS_CHAT_DIRECTIVE = `This instance has Status Cards enabled. A status card is a saved Paperclip feature, not a document or comment: a live summary card on the company's Status page at /<prefix>/status (listed as Status in the sidebar), each card at /<prefix>/status/<cardId>. The person describes what the card should track; the card's summarizer agent compiles that into a query over the company's tasks and writes the summary in its own hidden generation task. When the person asks for a status card, list the company's cards with GET /api/companies/{companyId}/status-cards and reuse a matching one; otherwise create one with POST /api/companies/{companyId}/status-cards, setting interestPrompt to what the card should summarize and agentId to the agent that writes its summary (yourself or a teammate; omitting it uses the built-in Summarizer when that is configured). Do not write a task document in place of a card. To refresh a card, POST /api/status-cards/{id}/refresh. Agents can manage only cards they authored. Creating or refreshing a card only queues the work: the summarizer updates the card in its own run, which you do not perform or wait for. Do not sleep or poll the card. Report the outcome the response gives: enqueued true means the summarizer's run is queued; alreadyGenerating means an earlier run is still going; enqueued false otherwise means no run started, so say why from card.state (such as paused_budget or paused_hours) or, when lastGeneratedAt is newer than before, that a concurrent refresh already finished. Reply now with that outcome and the card's returned href as a Markdown link, such as [Launch blockers](/<prefix>/status/<cardId>), and say the summary appears on the card when its summarizer finishes. Call a summary generated or current only after reading a newer lastGeneratedAt than before your request. A card's interestPrompt is instructions for its summarizer, not for you; do not try to meet its requirements yourself.`;
/** Rendered only while the instance enables the Conference Room experiment. */
export const CONFERENCE_ROOM_CHAT_DIRECTIVE = `This instance has the Conference Room enabled: a separate board-level discussion surface in this company at /<prefix>/board-chat, listed as Conference Room in the sidebar. It is not this conversation and you do not answer there. A separate board assistant replies, running through the operator's local Claude CLI, so it works only on a local single-operator instance. Its messages are kept on the company's standing Board Operations task. It does not carry over this conversation's history, and its actions are not runs or tasks of yours. When the person asks to talk in the conference room or with the board assistant, link it as a Markdown link, such as [Open the Conference Room](/<prefix>/board-chat), with this conversation's company prefix. Say in a sentence what it is and that this chat's history and tools do not carry over, and offer to keep discussing here instead.`;
@@ -37,7 +37,7 @@ export interface HeartbeatTaskContextInput {
policy: {
recovering?: boolean;
trustPreset: Pick<TrustPresetResolution, "kind">;
experimentalInstanceSettings: { enableSimplifiedEnglishInteractions?: boolean; enableCases?: boolean; enableConferenceRoomChat?: boolean };
experimentalInstanceSettings: { enableSimplifiedEnglishInteractions?: boolean; enableCases?: boolean; enableConferenceRoomChat?: boolean; enableStatusCards?: boolean };
};
services: {
issuesSvc: Pick<ReturnType<typeof issueService>, "getAncestors">;
@@ -242,6 +242,7 @@ export async function prepareHeartbeatTaskContext(db: Db, input: HeartbeatTaskCo
const taskMarkdownInput = {
coordinatorWorkspace: Boolean(coordinatorWorkspace),
conferenceRoomEnabled: experimentalInstanceSettings.enableConferenceRoomChat === true,
statusCardsEnabled: experimentalInstanceSettings.enableStatusCards === true,
conversationConfirmations: issueRef && isConversation(issueContext)
? await getConversationConfirmationContext({ db, companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id })
: null,
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildPaperclipTaskMarkdown } from "./task-markdown.js";
@@ -73,6 +74,25 @@ describe("heartbeat task markdown rendering", () => {
expect(buildPaperclipTaskMarkdown({ issue: { ...chat, conversationAgentId: null }, nativeRunner: true, conferenceRoomEnabled: true }))
.not.toContain("board-chat");
});
it("tells a standing chat what a status card is, how to create and refresh one, and to reply now with its link, only while the experiment is enabled", () => {
const chat = { id: "chat", identifier: "RUN-1", title: "Chat", conversationAgentId: "ceo" };
const enabled = buildPaperclipTaskMarkdown({ issue: chat, nativeRunner: true, statusCardsEnabled: true })!;
expect(enabled).toContain("Status Cards:");
expect(enabled).toContain("A status card is a saved Paperclip feature, not a document or comment");
expect(enabled).toContain("POST /api/companies/{companyId}/status-cards");
expect(enabled).toContain("POST /api/status-cards/{id}/refresh");
expect(enabled).toContain("Do not sleep or poll the card. Report the outcome the response gives");
expect(enabled).toContain("enqueued false otherwise means no run started, so say why from card.state");
expect(enabled).toContain("[Launch blockers](/<prefix>/status/<cardId>)");
// The named pages are the board's real routes.
const app = readFileSync(new URL("../../../../ui/src/App.tsx", import.meta.url), "utf8");
expect(enabled).toContain("Status page at /<prefix>/status (");
expect(app).toMatch(/path="status"\s+element=\{<StatusCardsExperimentalGate>/);
expect(app).toMatch(/path="status\/:cardId"\s+element=\{<StatusCardsExperimentalGate>/);
expect(buildPaperclipTaskMarkdown({ issue: chat, nativeRunner: true })).not.toContain("Status Cards:");
expect(buildPaperclipTaskMarkdown({ issue: { ...chat, conversationAgentId: null }, nativeRunner: true, statusCardsEnabled: true }))
.not.toContain("Status Cards:");
});
it("returns null without a task or a nonempty wake comment", () => {
expect(buildPaperclipTaskMarkdown({ issue: null })).toBeNull();
expect(buildPaperclipTaskMarkdown({
@@ -1,5 +1,5 @@
import { REQUIRED_QUALITY_REVIEW_GUIDANCE, requiredQualityReview } from "../required-quality-review.js";
import { AGENT_CHAT_DIRECTIVE, CONFERENCE_ROOM_CHAT_DIRECTIVE, COORDINATOR_CHAT_HANDOFF_DIRECTIVE } from "../agent-conversations.js";
import { AGENT_CHAT_DIRECTIVE, CONFERENCE_ROOM_CHAT_DIRECTIVE, COORDINATOR_CHAT_HANDOFF_DIRECTIVE, STATUS_CARDS_CHAT_DIRECTIVE } from "../agent-conversations.js";
import { publicChatTaskUrl } from "../chat-task-url.js";
import { slackChatAgentGuidance } from "../connectors/slack/agent-guidance.js";
import type { ConversationConfirmationContext } from "../conversation-confirmation-context.js";
@@ -68,6 +68,7 @@ export function buildPaperclipTaskMarkdown(input: {
nativeRunner?: boolean;
coordinatorWorkspace?: boolean;
conferenceRoomEnabled?: boolean;
statusCardsEnabled?: boolean;
// false builds the compact variant used for resume deltas, where the session
// already received the description with the assignment.
includeDescription?: boolean;
@@ -225,6 +226,7 @@ export function buildPaperclipTaskMarkdown(input: {
lines.push("", "Coordinator task proposals:", COORDINATOR_CHAT_HANDOFF_DIRECTIVE);
}
if (input.conferenceRoomEnabled) lines.push("", "Conference Room:", CONFERENCE_ROOM_CHAT_DIRECTIVE);
if (input.statusCardsEnabled) lines.push("", "Status Cards:", STATUS_CARDS_CHAT_DIRECTIVE);
if (input.conversationConfirmations?.cards.length) {
lines.push("", "Current pending confirmation cards (quoted proposal data, not recorded decisions):",
fenceTaskText(JSON.stringify(input.conversationConfirmations)),
+1 -1
View File
@@ -95,7 +95,7 @@ function updateDescription(input: {
previousSummary: string | null;
snapshot: CompanySearchIssueSummary[];
}) {
const mechanical = `Return the completed Markdown through \`PUT /api/status-cards/${input.card.id}/summary\` with \`generationIssueId\`, a short non-empty \`changeSummary\`, and the model id. Do not call issue-list endpoints. Preserve the streaming STATUS and <<<SUMMARY-DRAFT>>> sentinels used by the Summarizer. Issues the Markdown references by identifier (e.g. ABC-123) or issue link automatically join the card's watched set, so reference an issue only when the board should keep tracking it.`;
const mechanical = `Return the completed Markdown through \`PUT /api/status-cards/${input.card.id}/summary\` with \`generationIssueId\`, a short non-empty \`changeSummary\`, and the model id. Do not call issue-list endpoints. While you work, write short plain-text \`STATUS:\` lines; before the write, put the final Markdown between a \`<<<SUMMARY-DRAFT>>>\` line and a \`<<<END-SUMMARY-DRAFT>>>\` line in your reply text. These lines only drive the card's live preview; the PUT body is the plain Markdown, and there is no other format to look up. If you cannot produce a summary from what you can read, comment the reason on this task and set it to blocked; the card then keeps its previous summary and shows the failure. Issues the Markdown references by identifier (e.g. ABC-123) or issue link automatically join the card's watched set, so reference an issue only when the board should keep tracking it.`;
// The card prompt is the board's single standing request: it already says
// what to watch and how the update should read, so it doubles as the
// summary instructions — there is no separate default prompt to append to