Skip to main content
← All recipes
IntegrationIntermediate

Sign streaming AI responses

Streaming UI looks great but creates a chicken-and-egg for provenance: you want to sign the final output, but it doesn't exist until the stream ends. This recipe buffers chunks, signs the final assembly, and returns the receipt alongside.

The two questions to answer first

  • When do you sign? Answer: after the stream completes. Signing per-chunk creates fragmented receipts that don't represent the output the user actually saw.
  • How does the client get the receipt ID? Answer: append it as a final SSE event after the content stream ends, or as a trailer/footer in a custom protocol.

Anthropic SDK (Next.js route handler)

import Anthropic from '@anthropic-ai/sdk'
import { CertNode } from '@certnode/sdk'

const claude = new Anthropic()
const cert = new CertNode({ apiKey: process.env.CERTNODE_API_KEY! })

export async function POST(req: Request) {
  const { messages } = await req.json()

  const encoder = new TextEncoder()
  const stream = new ReadableStream({
    async start(controller) {
      const claudeStream = await claude.messages.stream({
        model: 'claude-opus-4-7',
        max_tokens: 4096,
        messages,
      })

      // Buffer chunks as they arrive, but ALSO stream them to the client
      let assembled = ''
      for await (const chunk of claudeStream) {
        if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
          assembled += chunk.delta.text
          controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'text', text: chunk.delta.text })}\n\n`))
        }
      }

      // Stream is done — sign the assembled output, then send the receipt
      const signed = await cert.signAIOutput({
        output: assembled,
        model: 'claude-opus-4-7',
        provider: 'anthropic',
      })

      controller.enqueue(encoder.encode(`data: ${JSON.stringify({
        type: 'receipt',
        receiptId: signed.receiptId,
        verifyUrl: signed.verifyUrl,
      })}\n\n`))
      controller.enqueue(encoder.encode('data: [DONE]\n\n'))
      controller.close()
    },
  })

  return new Response(stream, {
    headers: { 'Content-Type': 'text/event-stream' },
  })
}

OpenAI SDK (same pattern)

import OpenAI from 'openai'
import { CertNode } from '@certnode/sdk'

const openai = new OpenAI()
const cert = new CertNode({ apiKey: process.env.CERTNODE_API_KEY! })

export async function POST(req: Request) {
  const { messages } = await req.json()
  const completion = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages,
    stream: true,
  })

  const encoder = new TextEncoder()
  const stream = new ReadableStream({
    async start(controller) {
      let assembled = ''
      for await (const chunk of completion) {
        const delta = chunk.choices[0]?.delta?.content
        if (delta) {
          assembled += delta
          controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'text', text: delta })}\n\n`))
        }
      }

      const signed = await cert.signAIOutput({
        output: assembled,
        model: 'gpt-4o',
        provider: 'openai',
      })

      controller.enqueue(encoder.encode(`data: ${JSON.stringify({
        type: 'receipt',
        receiptId: signed.receiptId,
        verifyUrl: signed.verifyUrl,
      })}\n\n`))
      controller.close()
    },
  })

  return new Response(stream, {
    headers: { 'Content-Type': 'text/event-stream' },
  })
}

Client-side: capturing the receipt

const response = await fetch('/api/stream-with-receipt', {
  method: 'POST',
  body: JSON.stringify({ messages }),
})

const reader = response.body!.getReader()
const decoder = new TextDecoder()
let receiptId: string | null = null

while (true) {
  const { done, value } = await reader.read()
  if (done) break

  const text = decoder.decode(value)
  for (const line of text.split('\n')) {
    if (!line.startsWith('data: ')) continue
    const payload = line.slice(6)
    if (payload === '[DONE]') continue

    const event = JSON.parse(payload)
    if (event.type === 'text') {
      // Render the streaming chunk to the UI
      appendToOutput(event.text)
    } else if (event.type === 'receipt') {
      // Capture the receipt at end of stream
      receiptId = event.receiptId
      showVerifyLink(event.verifyUrl)
    }
  }
}

Failure modes to handle

  • Stream aborts mid-flight: catch the exception, decide whether to sign the partial output (and label itcontentType: 'ai_output' with a metadata flag) or skip signing entirely. Signing partials produces receipts that may not represent any output the user saw.
  • CertNode API down (rare, but plan for it): queue the signing for retry rather than failing the user-facing request. The streamed output is already delivered; signing is async observability.
  • Cap hit mid-stream: catch the 402 from CertNode and surface a soft warning in the receipt event ({ type: "receipt", error: "cap_exceeded" }). Don't fail the user-visible stream.