novo agents

Render a human review inbox

Approvals and questions share one durable interaction contract. This guide covers the details that matter when you surface those interactions in Slack, Teams, email, or your own operations UI.

Render the request, not the model transcript

Listen for data-novo-input-requested or list pending interactions with novo.interactions.list({ status: 'pending' }). For an approval, show the canonical toolName, the structured arguments in input, and the optional human-oriented preview. For a question, render request.details.questions and request.details.actions.

Treat the content as data: escape it for the target UI and never interpolate tool arguments as markup.

Group siblings without coupling them

A step may pause on several tool calls at once. Group cards by (rootRunId, stepNumber) so the reviewer understands that they belong to one agent step, but keep each card independently actionable. Resolving two of three siblings leaves the third pending and the run paused.

The public batch method mirrors that behavior and reports an outcome for each item:

const result = await novo.interactions.resolveBatch(rootRunId, {
  items: [
    {
      id: approveId,
      inputResponses: [
        { requestId: approveId, optionId: 'approve' },
      ],
    },
    {
      id: denyId,
      inputResponses: [
        { requestId: denyId, optionId: 'deny' },
      ],
      reason: 'outside the change window',
    },
  ],
});

for (const item of result.items) {
  // resolved | already_resolved | conflict | not_found | invalid_body
  console.log(item.id, item.status);
}

The HTTP route is POST /v1/runs/:rootRunId/interactions/resolve and accepts at most 20 items. Authentication or a malformed outer envelope fails the request; malformed or stale items are isolated to their own outcome.

Honor expiration and idempotency

Render a countdown from expiresAt; do not hardcode the park window. Re-read the interaction before a delayed action. Once an interaction is resolved or cancelled, expiresAt is null.

Button handlers should be idempotent. A repeated identical resolution reports already_resolved; a different second decision reports conflict. Disable the clicked card while the request is in flight, but do not disable its siblings.

Address approvals and questions correctly

For an approval, the inputResponses[].requestId is the same top-level interaction.id used as the batch item's id, and optionId is approve or deny.

For a question, address each inner request.details.questions[].id or selected request.details.actions[].id. The top-level interaction id belongs in the batch item id; it is not a question id.

Choose the simplest approval policy

Registered tool servers and HTTP connections can declare approval without a webhook:

await novo.toolServers.register({
  name: 'billing',
  transport: 'http',
  url: 'https://mcp.acme.dev',
  approval: 'once',
  gatedTools: ['issue_refund'],
});

once asks once per stable tool call and then remembers the approval, always asks on every call, and never never pauses. When gatedTools is present, listed tools use the declared policy and every unlisted tool uses never. This connection policy is evaluated first: calls that do not need review bypass the agent hook; calls that do need review use the hook when one is attached and otherwise pause for a human.

Keep the stream attached

After a resolution the same run resumes. Keep consuming its stream and reconnect after any non-terminal EOF. Remove a card when data-novo-input-resolved arrives; the batch response is an acknowledgement, while the stream is the durable UI truth.

See also