novo agents

Human in the loop

A long-running agent eventually wants to ask a human a question, or run a tool that you'd rather sign off on first. Both are the same primitive on the wire: an input request. The run pauses, you respond, the run resumes.

  • Question: the agent itself asks (ask_question). The request carries a rich payload: a prompt, optional structured options, free-form text.
  • Approval: a tool wants to fire and your hook parked it. An approval is just an input request whose options are [approve, deny].

Both pauses look identical on your side: a data-novo-input-requested chunk arrives mid-stream, the run's status flips to waiting_for_input (or waiting_for_approval), and one call to the SDK resumes it. You resolve both with the same inputResponses body: for an approval you select the approve or deny option; for a question you answer per the request's options.

The request shape

Every pause emits a data-novo-input-requested chunk. Its request describes what's being asked, and its shape depends on request.kind:

  • request.kind: 'approval_request' or 'question'.
  • request.display: how to render it, one of 'confirmation' (approvals), 'select' (pick an option), or 'text' (free-form).

Approval (kind: 'approval_request') carries the choices and tool call inline:

  • request.options: the choices you may select by id, always exactly [{ id: 'approve', label: 'Approve' }, { id: 'deny', label: 'Deny' }].
  • request.allowFreeform: false.
  • request.input and an optional rendered request.preview: the tool call under review.

Question (kind: 'question') carries the rich ask_question payload on request.details: { title, details?, questions?, actions? }. There are no top-level options or allowFreeform on a question's request: each prompt is an entry in request.details.questions[], and that entry holds its own options, multiline, allowOther, and multiple. Any review buttons live in request.details.actions[].

Resolving

You resolve every pause through the same call. Pass an inputResponses array; select an option by optionId, or supply text when free-form is allowed. The requestId in each entry depends on the kind:

  • Approval: use the interaction's own id (event.data.id; on the stream this also equals request.requestId), with optionId: 'approve' or 'deny'.
  • Question: use an inner details.questions[].id (or details.actions[].id), one entry per prompt you're answering. The interaction's top-level requestId is not a valid target for a question.
// Approve an approval (requestId is the interaction id).
await novo.interactions.resolve(interactionId, {
  inputResponses: [{ requestId, optionId: 'approve' }],
});

To cancel a pause without answering, pass cancel: true. An optional reason is echoed back on the resolution.

Questions

Enable on the agent, then handle the request when it arrives:

const agent = await novo.agents.create({
  context: 'You triage support tickets and confirm next steps with the user.',
  band: 'core',
  capabilities: { userInteraction: true },
});

const stream = await novo.threads.stream(thread.id, { input: 'Refund #ABC?' });

for await (const event of stream) {
  if (event.type === 'data-novo-input-requested' && event.data.request.kind === 'question') {
    const { details } = event.data.request;
    // Each `inputResponses[].requestId` is an INNER id from the payload: a
    // `details.questions[].id` (or a `details.actions[].id`), not the
    // interaction's top-level `request.requestId`. Pick an option the agent
    // offered, or supply free-form text.
    const question = details.questions[0];
    await novo.interactions.resolve(event.data.id, {
      inputResponses: [{ requestId: question.id, text: 'Customer confirmed via phone.' }],
    });
  }
}

The rich question payload (request.details) carries the agent's question(s) and any context the model attached. Each entry in details.questions[] defines a prompt, an optional list of structured options, and whether it accepts multiple selections (multiple) or free-form text (multiline / allowOther). Address it by its own id when you resolve: select one of its options by optionId, supply every chosen option for a multiple: true question in one optionIds array, or supply a free-form text answer. Each requestId may appear at most once; selecting more than one option for a single-select question returns 400. Addressing the interaction's top-level request.requestId instead resolves to an "Unknown questionId" and returns 400.

A run can pause for a question any number of times in one turn.

Approvals

There are two ways to require approval. The declarative path: set approval: 'once' | 'always' | 'never' (optionally scoped with gatedTools) on an HTTP connection or tool server: no hook to host; gated calls pause for a human. The dynamic path: a tool approval hook, a URL you host that decides per tool call. Either way, a pause fires a data-novo-input-requested chunk with request.kind: 'approval_request'; you resolve via novo.interactions.resolve(...), or several at once with novo.interactions.resolveBatch(...). From a hook, returning deny blocks the call without pausing; returning allow auto-accepts it (see Auto-accept).

for await (const event of stream) {
  if (event.type === 'data-novo-input-requested' && event.data.request.kind === 'approval_request') {
    const { id, toolName, request } = event.data;
    const ok = await yourTeam.review({ toolName, input: request.input, preview: request.preview });
    await novo.interactions.resolve(id, {
      inputResponses: [{ requestId: id, optionId: ok ? 'approve' : 'deny' }],
      reason: ok ? 'approved by on-call' : 'blocked',
    });
  }
}

Selecting deny resumes the run and the tool returns a terminal tool-output-denied chunk for its toolCallId. The reason you pass is echoed back verbatim on the resolution; it is not labelled by source.

Hook responses are 'allow' | 'deny' | 'pause'. To hard-fail the run when your hook itself errors (timeout, 5xx, signature mismatch), set onHookError: 'fail-run' on the hook registration. That's a separate policy from the per-call response.

Auto-accept

There are three ways an approval never reaches a human:

  • Connection-level approval: 'never' (or gatedTools that omit the tool). The declarative per-connection policy answers before any hook is consulted for that connection's tools.
  • Hook returns allow. A tool approval hook that returns { decision: 'allow' } for a call auto-accepts it: the tool fires without a pause. This is the per-call escape hatch.
  • approvals.autoAllow. A declarative list of Novo canonical tool names that never require approval. Listed tools skip the hook entirely and fire immediately, the analog of an always-allow list.
await novo.agents.update(agent.id, {
  approvals: {
    toolExecution: { hookId: hook.id },
    autoAllow: ['read_file', 'list_directory'],
  },
});

Use autoAllow for known-safe, read-only tools so your hook only sees the calls that actually warrant review.

Messages sent while a run is active

A text message sent to a thread whose run is still working is held, not dropped, whether the run is executing or parked on a pause. What happens first depends on the run's state:

  • Approval pause: if the message text matches approve / deny (or a 1-based 1 / 2), Novo resolves the approval with that decision and the run continues; the 409 thread_busy response carries details.resolved_interaction_id to show the message was consumed. Any other message is queued.
  • Question pause: the message is always queued, never auto-resolved. A question carries rich, multi-prompt answers, so it is answered only through the structured inputResponses resolve endpoint, not by free-text chat.
  • Running: the message is queued as mid-run steering; it is delivered to the agent at the run's next turn boundary (typically after the current batch of tool calls completes), so "actually, make it vegetarian" arrives while the work is still in flight instead of after the wrong result is finished. Text like "approve" is just a message here; there is no gate to resolve.
  • Queued in every case means held and delivered to the agent as its next user turn, so nothing the user typed is lost. A pause never auto-denies.

This means you can drive an approval purely by sending "approve" (or "deny", or "1") as the next message, while a question always waits for a structured resolve. Anything else sent mid-run is preserved and steered in, not bounced. To stop the run rather than steer it, use runs.cancel; a message never cancels.

A queued message is a first-class resource with a full lifecycle you can observe:

  • Accepted: the send still returns 409 thread_busy, but with details.queued: true and a details.followup_id handle. (A thread_busy with details.resolved_interaction_id means the message matched an approval and was consumed; a plain thread_busy without either field means nothing was held, e.g. the message carried attachments, the hold queue was full, or the run was still initializing; re-send when the run finishes.)
  • Held: until delivered, every held message is listed on the run resource as queuedFollowups ([{ id, text, queuedAt }], oldest-first), so any client, not just the one that sent it, can see what is waiting.
  • Withdrawn (optional): DELETE /v1/runs/:runId/followups/:followupId (SDK: novo.runs.followups.withdraw(runId, followupId)) removes a held message before it is delivered. A 404 followup_not_found means it is already gone: withdrawn earlier, claimed for delivery, or visible on a terminal run as a read-only undelivered record.
  • Delivered: at the next turn boundary (a resumed pause, or the next step of a running turn), each held message is folded into the conversation as the next user turn, oldest-first, and the stream emits one data-novo-followup-replayed chunk per message (carrying the same id and the verbatim text). Delivered user turns don't appear as assistant output, so this chunk is your cue to render the message in the transcript.
  • Never delivered: if the run reaches a terminal state before a boundary (completed, cancelled, or timed out while parked), held messages are not discarded silently: they remain visible in queuedFollowups on the terminal run, so a client can re-send them on a new run.

Reconnecting through a pause

A paused run does not bill model time. If your stream connection drops mid-pause, the run is still waiting; call stream.reconnect({ lastEventId }) and you'll pick up where you left off. The same data-novo-input-requested chunk replays so your handler is idempotent.

Abandoned pauses time out

A pause waits for a human, but not forever. If a pending approval or question is never resolved, Novo eventually cancels the run so the thread isn't held open indefinitely. The run finalizes with status: 'cancelled' and terminalReason: 'interaction_timeout'; any still-pending interactions on it are cancelled, and the thread is freed for new runs.

The window is deliberately generous, on the order of two weeks, so normal "approve it tomorrow" flows are never affected; it only catches genuinely abandoned pauses. A run that sets a longer maxDurationMs keeps its pause open for at least that long instead. To detect a timed-out pause, read terminalReason on the run (poll GET /v1/runs/:id or read await stream.finalResult) rather than assuming a pause lives forever.

Each pending interaction also carries expiresAt, the exact ISO timestamp this release fires, so a UI can show a live "expires in …" countdown without hardcoding the two-week window (it already reflects a longer maxDurationMs). It is present only while the interaction is pending and becomes null once it resolves; compute the value fresh by re-reading the interaction, since it tracks the run's live park anchor.

Out-of-band resolution

You don't have to resolve from inside the for-await loop. Use the id from the request chunk to resolve from any process. The run sits at waiting_for_input until you do:

await novo.interactions.resolve(interactionId, {
  inputResponses: [{ requestId, optionId: 'approve' }],
});

This pattern fits webhook-driven approval flows or Slack interactions where a human approves later.

Listing pending requests

const pending = await novo.interactions.list({
  status: 'pending',
});

const approvals = await novo.interactions.list({
  status: 'pending',
  kind: 'approval_request',
});

Both questions and approvals are interactions; filter by kind to split them. Useful for a "things waiting on a human" inbox in your app.