nexu-io/open-design · error · EmptyTranscriptError

EMPTY_TRANSCRIPT

EMPTY_TRANSCRIPT

Error message

conversation ${options.conversationId} has no messages to hand off

What it means

EmptyTranscriptError: exportProjectTranscript returned messageCount === 0 for the given conversationId. synthesizeHandoffPrompt refuses to spend BYOK tokens fabricating a resume prompt from an empty conversation. The route maps this to 400 EMPTY_TRANSCRIPT.

Source

Thrown at apps/daemon/src/design/handoff-design.ts:180

): Promise<HandoffResponse> {
  const project = getProject(db, projectId);
  if (!project) {
    throw new Error(`project not found: ${projectId}`);
  }

  const now = options.now ?? (() => new Date());
  const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
  const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;

  const transcriptResult = exportProjectTranscript(db, projectsRoot, projectId, {
    now,
    conversationId: options.conversationId,
  });
  // Fail fast on an empty conversation: synthesizing a handoff from zero
  // messages would spend BYOK tokens to fabricate context that does not
  // exist. The route maps EmptyTranscriptError to 400.
  if (transcriptResult.messageCount === 0) {
    throw new EmptyTranscriptError(
      `conversation ${options.conversationId} has no messages to hand off`,
    );
  }
  const transcriptJsonl = fs.readFileSync(transcriptResult.path, 'utf8');
  const truncatedJsonl = truncateTranscriptForPrompt(transcriptJsonl);

  const { systemPrompt, userPrompt } = buildHandoffPrompt({
    projectId,
    transcriptJsonl: truncatedJsonl,
    transcriptMessageCount: transcriptResult.messageCount,
    now: now(),
  });

  const timeoutController = new AbortController();
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  // The timeout must stay armed until the response BODY has been fully
  // read, not just until headers arrive. `fetch()` resolves as soon as the
  // upstream sends headers, so clearing the timeout before `response.json()`

View on GitHub (pinned to 5be4028344)

Solutions

  1. Send at least one message in the conversation before requesting a handoff.
  2. Verify the conversationId is correct and belongs to the project.
  3. Check the conversation has messages via the chat history API before calling handoff.
Defensive patterns

Strategy: validation

Validate before calling

// Confirm the conversation has messages before requesting a handoff.
import { exportProjectTranscript } from '../transcript-export.js';
const probe = exportProjectTranscript(db, projectsRoot, projectId,
  { now: () => new Date(), conversationId });
if (probe.messageCount === 0) {
  return res.status(400).json({ code: 'EMPTY_TRANSCRIPT', error: 'send a message first' });
}

Type guard

import { EmptyTranscriptError } from './handoff-design.js';
function isEmptyTranscript(err: unknown): err is EmptyTranscriptError {
  return err instanceof EmptyTranscriptError;
}

Try / catch

import { EmptyTranscriptError } from './handoff-design.js';
try {
  const handoff = await synthesizeHandoffPrompt(db, projectsRoot, projectId, options);
} catch (err) {
  if (err instanceof EmptyTranscriptError) {
    // Route maps to 400 EMPTY_TRANSCRIPT; prompt the user to send a message first.
    return res.status(400).json({ code: 'EMPTY_TRANSCRIPT', error: err.message });
  }
  throw err;
}

Prevention

When it happens

Trigger: Calling synthesizeHandoffPrompt (POST /api/projects/:id/handoff) with a conversationId that has zero messages — a freshly created conversation, a conversation whose messages were all deleted, or a wrong/mismatched conversationId.

Common situations: User clicked 'handoff' on a brand-new conversation before sending any message; conversationId doesn't match any real conversation; messages were cleared; the conversation belongs to a different project.

Related errors


AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12). Data as JSON: /api/errors/d47aaf9a1ec1d53b. Report an issue: GitHub.