novo agents

Signed callbacks

Novo signs outbound calls to customer-owned callback surfaces: environments, result handlers, and tool approval hooks.

Run output and lifecycle events are not delivered through a separate webhook product. They are delivered on the durable run stream. Server-side consumers should use runs.start plus runs.stream(runId) and persist the stream chunks they care about.

Signature verification

Every signed request carries a Novo-Signature header:

Novo-Signature: t=1716936000,v1=hex...

t is the Unix timestamp of signing. v1 is hmac_sha256(secret, "${t}.${rawBody}") in hex.

Verify with the SDK:

import { verifyNovoSignature } from 'novoagents';

const rawBody = await req.text();
const result = verifyNovoSignature({
  rawBody,
  signatureHeader: req.headers.get('Novo-Signature')!,
  secret: process.env.NOVO_CALLBACK_SECRET!,
});

if (!result.valid) return new Response('bad signature', { status: 401 });

verifyNovoSignature checks:

  1. Header shape: reason: 'signature_header_invalid'.
  2. Timestamp tolerance: reason: 'timestamp_outside_tolerance'.
  3. HMAC equality: reason: 'signature_mismatch'.

Always verify before parsing or acting on a callback body. Do not log the body of an unverified callback.

Request metadata

Signed callbacks include stable headers you can use for routing and audit:

Novo-Signature: t=...,v1=...
Novo-Timestamp: ...
Novo-Request-Id: req_...
Novo-Org-Id: org_...
Novo-Agent-Id: agent_...
Novo-Run-Id: run_...

Specific callback families add their own resource headers, such as Novo-Environment-Id, Novo-Result-Handler-Id, or Novo-Tool-Approval-Hook-Id.

Idempotency

Callbacks are retried when a delivery returns non-2xx or times out. Treat Novo-Request-Id as the idempotency key for the logical callback and make your handler safe to receive the same request more than once.

Run events

Use the stream for run events:

const receipt = await novo.runs.start({ threadId, input });
let stream = await novo.runs.stream(receipt.runId);

for (;;) {
  for await (const event of stream) {
    await persistChunk(event, stream.lastEventId);
  }
  if (stream.reachedTerminal) break;
  stream = await stream.reconnect();
}

For the common “just drain until done” case, use await stream.streamToCompletion().